diff options
Diffstat (limited to 'app')
1853 files changed, 21619 insertions, 12290 deletions
diff --git a/app/assets/images/web-ide-promo-popover.svg b/app/assets/images/web-ide-promo-popover.svg new file mode 100644 index 00000000000..3ced89860da --- /dev/null +++ b/app/assets/images/web-ide-promo-popover.svg @@ -0,0 +1,101 @@ +<svg width="280" height="140" viewBox="0 0 280 140" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_187_122567)"> +<g clip-path="url(#clip1_187_122567)"> +<circle cx="189.5" cy="-42.5" r="131.5" fill="url(#paint0_radial_187_122567)"/> +<circle cx="-41.5" cy="-97.5" r="198.5" fill="url(#paint1_radial_187_122567)"/> +<circle cx="309.5" cy="-7.5" r="121.5" fill="url(#paint2_radial_187_122567)"/> +<g filter="url(#filter0_b_187_122567)"> +<path d="M0 4C0 1.79086 1.79086 0 4 0H276C278.209 0 280 1.79086 280 4V130H0V4Z" fill="white" fill-opacity="0.01"/> +</g> +</g> +<path d="M183.948 47.9647H100.897V100.482H183.948V47.9647Z" fill="white"/> +<path d="M100.64 47.9647H184.06V98.9817C184.06 104.1 179.908 108.256 174.793 108.256H100.638V47.9647H100.64Z" fill="white"/> +<path d="M184.314 34.7452H100.64V47.9676H184.314V34.7452Z" fill="#AEA5D6"/> +<path d="M109.594 43.2574C110.644 43.2574 111.495 42.4056 111.495 41.3549C111.495 40.3043 110.644 39.4525 109.594 39.4525C108.544 39.4525 107.693 40.3043 107.693 41.3549C107.693 42.4056 108.544 43.2574 109.594 43.2574Z" fill="#10B1B1"/> +<path d="M116.482 43.2574C117.532 43.2574 118.383 42.4056 118.383 41.3549C118.383 40.3043 117.532 39.4525 116.482 39.4525C115.432 39.4525 114.581 40.3043 114.581 41.3549C114.581 42.4056 115.432 43.2574 116.482 43.2574Z" fill="#A888F4"/> +<path d="M123.368 43.2574C124.418 43.2574 125.269 42.4056 125.269 41.3549C125.269 40.3043 124.418 39.4525 123.368 39.4525C122.318 39.4525 121.467 40.3043 121.467 41.3549C121.467 42.4056 122.318 43.2574 123.368 43.2574Z" fill="#FF9D73"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M100.556 34.4038H165.377V35.0858H101.238V61.7159H100.556V34.4038Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M101.238 66.3383V83.4486H100.556V66.3383H101.238Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M183.721 99.0231V89.5341H184.403V99.0231C184.403 104.311 180.118 108.599 174.834 108.599H120.244V107.917H174.834C179.741 107.917 183.721 103.935 183.721 99.0231Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M183.721 83.1296V62.2422H184.403V83.1296H183.721Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M109.596 39.7936C108.734 39.7936 108.036 40.4924 108.036 41.355C108.036 42.2176 108.734 42.9164 109.596 42.9164C110.457 42.9164 111.155 42.2176 111.155 41.355C111.155 40.4924 110.457 39.7936 109.596 39.7936ZM107.354 41.355C107.354 40.1162 108.357 39.1116 109.596 39.1116C110.834 39.1116 111.837 40.1162 111.837 41.355C111.837 42.5938 110.834 43.5985 109.596 43.5985C108.357 43.5985 107.354 42.5938 107.354 41.355Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M116.48 39.7936C115.619 39.7936 114.92 40.4924 114.92 41.355C114.92 42.2176 115.619 42.9164 116.48 42.9164C117.342 42.9164 118.04 42.2176 118.04 41.355C118.04 40.4924 117.342 39.7936 116.48 39.7936ZM114.238 41.355C114.238 40.1162 115.242 39.1116 116.48 39.1116C117.719 39.1116 118.722 40.1162 118.722 41.355C118.722 42.5938 117.719 43.5985 116.48 43.5985C115.242 43.5985 114.238 42.5938 114.238 41.355Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M123.367 39.7936C122.506 39.7936 121.808 40.4924 121.808 41.355C121.808 42.2176 122.506 42.9164 123.367 42.9164C124.229 42.9164 124.927 42.2176 124.927 41.355C124.927 40.4924 124.229 39.7936 123.367 39.7936ZM121.125 41.355C121.125 40.1162 122.129 39.1116 123.367 39.1116C124.606 39.1116 125.609 40.1162 125.609 41.355C125.609 42.5938 124.606 43.5985 123.367 43.5985C122.129 43.5985 121.125 42.5938 121.125 41.355Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M160.466 48.3058H100.897V47.6238H160.466V48.3058Z" fill="#171321"/> +<path d="M173.511 80.7124H152.485V83.4598H173.511V80.7124Z" fill="#D0C5E2"/> +<path d="M149.08 80.7009H111.703V83.4484H149.08V80.7009Z" fill="#E7E4F2"/> +<path d="M111.702 68.5293H132.728V65.7819H111.702V68.5293Z" fill="#E7E4F2"/> +<path d="M136.131 68.5336H173.508V65.7861H136.131V68.5336Z" fill="#AEA5D6"/> +<path d="M111.703 75.9889H120.838V73.2415H111.703V75.9889Z" fill="#AEA5D6"/> +<path d="M155.891 75.9978H173.512V73.2504H155.891V75.9978Z" fill="#E7E4F2"/> +<path d="M124.244 75.9978H152.485V73.2504H124.244V75.9978Z" fill="#D0C5E2"/> +<path d="M141.099 58.3217H124.332V61.0691H141.099V58.3217Z" fill="#D0C5E2"/> +<path d="M173.512 58.3217H144.31V61.0691H173.512V58.3217Z" fill="#E7E4F2"/> +<path d="M120.926 58.3198H111.703V61.0673H120.926V58.3198Z" fill="#AEA5D6"/> +<path d="M144.115 90.9242H160.882V88.1768H144.115V90.9242Z" fill="#AEA5D6"/> +<path d="M111.703 90.908H140.905V88.1605H111.703V90.908Z" fill="#D0C5E2"/> +<path d="M164.288 90.9242H173.512V88.1768H164.288V90.9242Z" fill="#D0C5E2"/> +<path d="M173.508 95.6178H158.224V98.3652H173.508V95.6178Z" fill="#D0C5E2"/> +<path d="M154.82 95.6199H111.703V98.3673H154.82V95.6199Z" fill="#E7E4F2"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M167.801 34.8965L168.463 35.0598L162.398 59.6461L189.091 57.5112L189.145 58.1911L161.509 60.4014L167.801 34.8965Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M134.795 81.6335L109.428 116.595L108.876 116.195L133.312 82.5167L92.5055 87.8873L92.4165 87.2111L134.795 81.6335Z" fill="#171321"/> +<path d="M187.019 57.9366C197.646 57.9366 206.262 49.3147 206.262 38.6791C206.262 28.0435 197.646 19.4216 187.019 19.4216C176.392 19.4216 167.777 28.0435 167.777 38.6791C167.777 49.3147 176.392 57.9366 187.019 57.9366Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M187.019 19.6948C176.543 19.6948 168.05 28.1943 168.05 38.6794C168.05 49.1646 176.543 57.6641 187.019 57.6641C197.495 57.6641 205.989 49.1646 205.989 38.6794C205.989 28.1943 197.495 19.6948 187.019 19.6948ZM167.504 38.6794C167.504 27.8934 176.241 19.1492 187.019 19.1492C197.797 19.1492 206.534 27.8934 206.534 38.6794C206.534 49.4655 197.797 58.2097 187.019 58.2097C176.241 58.2097 167.504 49.4655 167.504 38.6794Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M212.255 38.6791C212.255 24.7294 200.956 13.4218 187.018 13.4218V12.677C201.368 12.677 213 24.3186 213 38.6791H212.255Z" fill="#AEA5D6"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M67.0944 108.321C67.0944 122.27 78.3936 133.578 92.3316 133.578V134.323C77.9817 134.323 66.3496 122.681 66.3496 108.321H67.0944Z" fill="#AEA5D6"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M181.794 28.4035C183.212 28.0221 184.673 28.8643 185.054 30.2837L184.395 30.4605C184.112 29.4049 183.026 28.7787 181.971 29.0622L181.794 28.4035Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M192.066 29.0622C191.011 28.7787 189.925 29.4049 189.642 30.4605L188.983 30.2837C189.364 28.8643 190.824 28.0221 192.243 28.4035L192.066 29.0622Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M195.892 33.5398V36.1862L192.04 37.4786L191.823 36.832L195.21 35.6956V33.5398H195.892Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M195.213 44.8355L192.045 43.7774L192.262 43.1305L195.895 44.3442V46.991H195.213V44.8355Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M178.827 35.6957V33.5398H178.145V36.1861L181.995 37.4786L182.212 36.832L178.827 35.6957Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M181.776 43.1305L181.992 43.7773L178.827 44.8354V46.991H178.145V44.3443L181.776 43.1305Z" fill="#171321"/> +<path d="M182.675 34.3942L181.129 37.5941C179.03 41.9415 182.192 46.9908 187.019 46.9908C191.845 46.9908 195.008 41.9415 192.908 37.5941L191.363 34.3942" fill="#A888F4"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M186.678 46.9908V34.3942H187.36V46.9908H186.678Z" fill="#171321"/> +<path d="M191.25 34.3939H182.788V33.2534C182.788 28.2918 191.25 28.2987 191.25 33.2534V34.3939Z" fill="#7759C1"/> +<path d="M92.4612 126.064C103.088 126.064 111.704 117.442 111.704 106.806C111.704 96.1706 103.088 87.5487 92.4612 87.5487C81.8339 87.5487 73.2188 96.1706 73.2188 106.806C73.2188 117.442 81.8339 126.064 92.4612 126.064Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M92.4604 87.89C82.0217 87.89 73.559 96.359 73.559 106.806C73.559 117.254 82.0217 125.723 92.4604 125.723C102.899 125.723 111.362 117.254 111.362 106.806C111.362 96.359 102.899 87.89 92.4604 87.89ZM72.877 106.806C72.877 95.9828 81.6445 87.208 92.4604 87.208C103.276 87.208 112.044 95.9828 112.044 106.806C112.044 117.63 103.276 126.405 92.4604 126.405C81.6445 126.405 72.877 117.63 72.877 106.806Z" fill="#171321"/> +<path d="M98.2885 105.28H95.9653V101.385C95.9653 99.3302 94.2951 97.6587 92.2419 97.6587C90.1887 97.6587 88.5184 99.3302 88.5184 101.385V105.28H86.1953V101.385C86.1953 98.0489 88.9083 95.3337 92.2419 95.3337C95.5754 95.3337 98.2885 98.0489 98.2885 101.385V105.28Z" fill="#7759C1"/> +<path d="M100.894 104.186H83.3677V116.836H100.894V104.186Z" fill="#A888F4"/> +<path d="M92.1343 111.015C92.8235 111.015 93.3823 110.456 93.3823 109.766C93.3823 109.076 92.8235 108.517 92.1343 108.517C91.445 108.517 90.8862 109.076 90.8862 109.766C90.8862 110.456 91.445 111.015 92.1343 111.015Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M91.792 112.209V110.722H92.474V112.209H91.792Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M87.2401 53.2567V60.9103C87.2401 61.9966 88.1212 62.878 89.2061 62.878H121.061C122.523 62.878 123.709 64.0653 123.709 65.5278V67.1601H123.027V65.5278C123.027 64.4415 122.146 63.5601 121.061 63.5601H89.2061C87.7441 63.5601 86.5581 62.3728 86.5581 60.9103V53.2567H87.2401Z" fill="#171321"/> +<path d="M123.368 67.735C123.685 67.735 123.942 67.4776 123.942 67.1601C123.942 66.8426 123.685 66.5852 123.368 66.5852C123.051 66.5852 122.793 66.8426 122.793 67.1601C122.793 67.4776 123.051 67.735 123.368 67.735Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M123.368 66.9262C123.239 66.9262 123.134 67.0306 123.134 67.16C123.134 67.2895 123.239 67.3939 123.368 67.3939C123.496 67.3939 123.601 67.2895 123.601 67.16C123.601 67.0306 123.496 66.9262 123.368 66.9262ZM122.452 67.16C122.452 66.6545 122.862 66.2441 123.368 66.2441C123.873 66.2441 124.283 66.6545 124.283 67.16C124.283 67.6656 123.873 68.0759 123.368 68.0759C122.862 68.0759 122.452 67.6656 122.452 67.16Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M167.208 82.5551V84.88C167.208 85.5854 167.778 86.1551 168.482 86.1551H193.305V86.8371H168.482C167.4 86.8371 166.526 85.9616 166.526 84.88V82.5551H167.208Z" fill="#171321"/> +<path d="M166.867 83.13C167.184 83.13 167.441 82.8726 167.441 82.5551C167.441 82.2376 167.184 81.9802 166.867 81.9802C166.55 81.9802 166.292 82.2376 166.292 82.5551C166.292 82.8726 166.55 83.13 166.867 83.13Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M166.866 82.3209C166.737 82.3209 166.633 82.4254 166.633 82.5548C166.633 82.6842 166.737 82.7887 166.866 82.7887C166.995 82.7887 167.1 82.6842 167.1 82.5548C167.1 82.4255 166.995 82.3209 166.866 82.3209ZM165.951 82.5548C165.951 82.0492 166.36 81.6389 166.866 81.6389C167.372 81.6389 167.782 82.0492 167.782 82.5548C167.782 83.0605 167.372 83.4707 166.866 83.4707C166.36 83.4707 165.951 83.0604 165.951 82.5548Z" fill="#171321"/> +<path d="M86.9023 37.1553C82.8536 41.5743 77.6099 40.6 77.6099 40.6V48.5768C77.6099 49.962 77.8867 51.3404 78.4796 52.5917C80.8973 57.6918 86.9023 59.5873 86.9023 59.5873C86.9023 59.5873 92.9051 57.6918 95.3251 52.5917C95.918 51.3404 96.1948 49.962 96.1948 48.5768V40.6C96.1948 40.6 90.9511 41.5743 86.9023 37.1553V37.1553Z" fill="#10B1B1"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M90.6411 45.6638L85.4674 50.8415L83.1592 48.5289L83.6419 48.0471L85.4677 49.8764L90.1586 45.1818L90.6411 45.6638Z" fill="#171321"/> +<path d="M197.955 76.3168C193.902 80.7427 188.651 79.7684 188.651 79.7684V87.7567C188.651 89.1443 188.928 90.5249 189.521 91.7786C191.943 96.8856 197.955 98.7834 197.955 98.7834C197.955 98.7834 203.967 96.8856 206.39 91.7786C206.985 90.5249 207.259 89.1443 207.259 87.7567V79.7684C207.259 79.7684 202.009 80.7427 197.955 76.3168Z" fill="#FC6D26"/> +<path d="M197.19 83.1044L197.566 88.4584H198.343L198.719 83.1044H197.19Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M196.806 82.7468H199.102L198.676 88.8156H197.232L196.806 82.7468ZM197.572 83.4616L197.898 88.1009H198.009L198.335 83.4616H197.572Z" fill="#171321"/> +<path d="M198.343 90.2083H197.566V91.1248H198.343V90.2083Z" fill="#171321"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M197.208 89.851H198.7V91.4823H197.208V89.851ZM197.922 90.5657V90.7675H197.985V90.5657H197.922Z" fill="#171321"/> +</g> +<defs> +<filter id="filter0_b_187_122567" x="-50" y="-50" width="380" height="230" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feGaussianBlur in="BackgroundImageFix" stdDeviation="25"/> +<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_187_122567"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_187_122567" result="shape"/> +</filter> +<radialGradient id="paint0_radial_187_122567" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(189.5 -42.5) rotate(89.5818) scale(125.986)"> +<stop stop-color="#7759C2"/> +<stop offset="1" stop-color="#7759C2" stop-opacity="0"/> +</radialGradient> +<radialGradient id="paint1_radial_187_122567" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(-41.5 -97.5) rotate(89.5818) scale(190.176)"> +<stop stop-color="#D64028"/> +<stop offset="1" stop-color="#D64028" stop-opacity="0"/> +</radialGradient> +<radialGradient id="paint2_radial_187_122567" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(309.5 -7.5) rotate(89.5818) scale(116.405)"> +<stop stop-color="#EF76F1"/> +<stop offset="1" stop-color="#EF76F1" stop-opacity="0"/> +</radialGradient> +<clipPath id="clip0_187_122567"> +<rect width="280" height="140" fill="white"/> +</clipPath> +<clipPath id="clip1_187_122567"> +<path d="M0 4C0 1.79086 1.79086 0 4 0H276C278.209 0 280 1.79086 280 4V140H0V4Z" fill="white"/> +</clipPath> +</defs> +</svg> diff --git a/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue b/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue index 80c216024a0..8e814cd55ef 100644 --- a/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue +++ b/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue @@ -1,5 +1,5 @@ <script> -import { GlListbox } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { s__ } from '~/locale'; import { setUrlParams, visitUrl } from '~/lib/utils/url_utility'; @@ -9,7 +9,7 @@ export default { database: s__('BackgroundMigrations|Database'), }, components: { - GlListbox, + GlCollapsibleListbox, }, props: { databases: { @@ -39,7 +39,7 @@ export default { <label id="label" class="gl-font-weight-bold gl-mr-4 gl-mb-0">{{ $options.i18n.database }}</label> - <gl-listbox + <gl-collapsible-listbox v-model="selected" :items="databases" right diff --git a/app/assets/javascripts/admin/broadcast_messages/components/base.vue b/app/assets/javascripts/admin/broadcast_messages/components/base.vue index b7bafe46327..f869d21d55f 100644 --- a/app/assets/javascripts/admin/broadcast_messages/components/base.vue +++ b/app/assets/javascripts/admin/broadcast_messages/components/base.vue @@ -5,14 +5,18 @@ import { buildUrlWithCurrentLocation } from '~/lib/utils/common_utils'; import { createAlert, VARIANT_DANGER } from '~/flash'; import { s__ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; +import { NEW_BROADCAST_MESSAGE } from '../constants'; +import MessageForm from './message_form.vue'; import MessagesTable from './messages_table.vue'; const PER_PAGE = 20; export default { name: 'BroadcastMessagesBase', + NEW_BROADCAST_MESSAGE, components: { GlPagination, + MessageForm, MessagesTable, }, @@ -97,6 +101,7 @@ export default { <template> <div> + <message-form :broadcast-message="$options.NEW_BROADCAST_MESSAGE" /> <messages-table v-if="hasVisibleMessages" :messages="visibleMessages" diff --git a/app/assets/javascripts/admin/broadcast_messages/components/datetime_picker.vue b/app/assets/javascripts/admin/broadcast_messages/components/datetime_picker.vue new file mode 100644 index 00000000000..07814ef2511 --- /dev/null +++ b/app/assets/javascripts/admin/broadcast_messages/components/datetime_picker.vue @@ -0,0 +1,47 @@ +<script> +import { GlDatepicker, GlFormInput } from '@gitlab/ui'; +import { dateToTimeInputValue, timeToHoursMinutes } from '~/lib/utils/datetime/date_format_utility'; + +export default { + name: 'DatetimePicker', + components: { + GlDatepicker, + GlFormInput, + }, + props: { + value: { + type: Date, + required: true, + }, + }, + computed: { + date: { + get() { + return this.value; + }, + set(val) { + const dup = new Date(this.value.getTime()); + dup.setFullYear(val.getFullYear(), val.getMonth(), val.getDate()); + this.$emit('input', dup); + }, + }, + time: { + get() { + return dateToTimeInputValue(this.value); + }, + set(val) { + const dup = new Date(this.value.getTime()); + const { hours, minutes } = timeToHoursMinutes(val); + dup.setHours(hours, minutes); + this.$emit('input', dup); + }, + }, + }, +}; +</script> +<template> + <div class="gl-display-flex gl-gap-3 gl-align-items-center"> + <gl-datepicker v-model="date" /> + <gl-form-input v-model="time" size="sm" type="time" data-testid="time-picker" /> + </div> +</template> diff --git a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue new file mode 100644 index 00000000000..36796708e78 --- /dev/null +++ b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue @@ -0,0 +1,225 @@ +<script> +import { + GlButton, + GlBroadcastMessage, + GlForm, + GlFormCheckbox, + GlFormCheckboxGroup, + GlFormInput, + GlFormSelect, + GlFormText, + GlFormTextarea, +} from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { s__ } from '~/locale'; +import { createAlert, VARIANT_DANGER } from '~/flash'; +import { redirectTo } from '~/lib/utils/url_utility'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { BROADCAST_MESSAGES_PATH, THEMES, TYPES, TYPE_BANNER } from '../constants'; +import MessageFormGroup from './message_form_group.vue'; +import DatetimePicker from './datetime_picker.vue'; + +const FORM_HEADERS = { headers: { 'Content-Type': 'application/json; charset=utf-8' } }; + +export default { + name: 'MessageForm', + components: { + DatetimePicker, + GlButton, + GlBroadcastMessage, + GlForm, + GlFormCheckbox, + GlFormCheckboxGroup, + GlFormInput, + GlFormSelect, + GlFormText, + GlFormTextarea, + MessageFormGroup, + }, + mixins: [glFeatureFlagsMixin()], + inject: ['targetAccessLevelOptions'], + i18n: { + message: s__('BroadcastMessages|Message'), + messagePlaceholder: s__('BroadcastMessages|Your message here'), + type: s__('BroadcastMessages|Type'), + theme: s__('BroadcastMessages|Theme'), + dismissable: s__('BroadcastMessages|Dismissable'), + dismissableDescription: s__('BroadcastMessages|Allow users to dismiss the broadcast message'), + targetRoles: s__('BroadcastMessages|Target roles'), + targetRolesDescription: s__( + 'BroadcastMessages|The broadcast message displays only to users in projects and groups who have these roles.', + ), + targetPath: s__('BroadcastMessages|Target Path'), + targetPathDescription: s__('BroadcastMessages|Paths can contain wildcards, like */welcome'), + startsAt: s__('BroadcastMessages|Starts at'), + endsAt: s__('BroadcastMessages|Ends at'), + add: s__('BroadcastMessages|Add broadcast message'), + addError: s__('BroadcastMessages|There was an error adding broadcast message.'), + update: s__('BroadcastMessages|Update broadcast message'), + updateError: s__('BroadcastMessages|There was an error updating broadcast message.'), + }, + messageThemes: THEMES, + messageTypes: TYPES, + props: { + broadcastMessage: { + type: Object, + required: true, + }, + }, + data() { + return { + loading: false, + message: this.broadcastMessage.message, + type: this.broadcastMessage.broadcastType, + theme: this.broadcastMessage.theme, + dismissable: this.broadcastMessage.dismissable || false, + targetPath: this.broadcastMessage.targetPath, + targetAccessLevels: this.broadcastMessage.targetAccessLevels, + targetAccessLevelOptions: this.targetAccessLevelOptions.map(([text, value]) => ({ + text, + value, + })), + startsAt: new Date(this.broadcastMessage.startsAt.getTime()), + endsAt: new Date(this.broadcastMessage.endsAt.getTime()), + }; + }, + computed: { + isBanner() { + return this.type === TYPE_BANNER; + }, + messageBlank() { + return this.message.trim() === ''; + }, + messagePreview() { + return this.messageBlank ? this.$options.i18n.messagePlaceholder : this.message; + }, + isAddForm() { + return !this.broadcastMessage.id; + }, + formPath() { + return this.isAddForm + ? BROADCAST_MESSAGES_PATH + : `${BROADCAST_MESSAGES_PATH}/${this.broadcastMessage.id}`; + }, + formPayload() { + return JSON.stringify({ + message: this.message, + broadcast_type: this.type, + theme: this.theme, + dismissable: this.dismissable, + target_path: this.targetPath, + target_access_levels: this.targetAccessLevels, + starts_at: this.startsAt.toISOString(), + ends_at: this.endsAt.toISOString(), + }); + }, + }, + methods: { + async onSubmit() { + this.loading = true; + + const success = await this.submitForm(); + if (success) { + redirectTo(BROADCAST_MESSAGES_PATH); + } else { + this.loading = false; + } + }, + + async submitForm() { + const requestMethod = this.isAddForm ? 'post' : 'patch'; + + try { + await axios[requestMethod](this.formPath, this.formPayload, FORM_HEADERS); + } catch (e) { + const message = this.isAddForm + ? this.$options.i18n.addError + : this.$options.i18n.updateError; + createAlert({ message, variant: VARIANT_DANGER }); + return false; + } + return true; + }, + }, +}; +</script> +<template> + <gl-form @submit.prevent="onSubmit"> + <gl-broadcast-message class="gl-my-6" :type="type" :theme="theme" :dismissible="dismissable"> + {{ messagePreview }} + </gl-broadcast-message> + + <message-form-group :label="$options.i18n.message" label-for="message-textarea"> + <gl-form-textarea + id="message-textarea" + v-model="message" + size="sm" + :placeholder="$options.i18n.messagePlaceholder" + /> + </message-form-group> + + <message-form-group :label="$options.i18n.type" label-for="type-select"> + <gl-form-select id="type-select" v-model="type" :options="$options.messageTypes" /> + </message-form-group> + + <template v-if="isBanner"> + <message-form-group :label="$options.i18n.theme" label-for="theme-select"> + <gl-form-select + id="theme-select" + v-model="theme" + :options="$options.messageThemes" + data-testid="theme-select" + /> + </message-form-group> + + <message-form-group :label="$options.i18n.dismissable" label-for="dismissable-checkbox"> + <gl-form-checkbox + id="dismissable-checkbox" + v-model="dismissable" + class="gl-mt-3" + data-testid="dismissable-checkbox" + > + <span>{{ $options.i18n.dismissableDescription }}</span> + </gl-form-checkbox> + </message-form-group> + </template> + + <message-form-group + v-if="glFeatures.roleTargetedBroadcastMessages" + :label="$options.i18n.targetRoles" + data-testid="target-roles-checkboxes" + > + <gl-form-checkbox-group v-model="targetAccessLevels" :options="targetAccessLevelOptions" /> + <gl-form-text> + {{ $options.i18n.targetRolesDescription }} + </gl-form-text> + </message-form-group> + + <message-form-group :label="$options.i18n.targetPath" label-for="target-path-input"> + <gl-form-input id="target-path-input" v-model="targetPath" /> + <gl-form-text> + {{ $options.i18n.targetPathDescription }} + </gl-form-text> + </message-form-group> + + <message-form-group :label="$options.i18n.startsAt"> + <datetime-picker v-model="startsAt" /> + </message-form-group> + + <message-form-group :label="$options.i18n.endsAt"> + <datetime-picker v-model="endsAt" /> + </message-form-group> + + <div class="form-actions gl-mb-3"> + <gl-button + type="submit" + variant="confirm" + :loading="loading" + :disabled="messageBlank" + data-testid="submit-button" + > + {{ isAddForm ? $options.i18n.add : $options.i18n.update }} + </gl-button> + </div> + </gl-form> +</template> diff --git a/app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue b/app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue new file mode 100644 index 00000000000..eec51c0c28b --- /dev/null +++ b/app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue @@ -0,0 +1,34 @@ +<script> +import { GlFormGroup } from '@gitlab/ui'; + +export default { + name: 'MessageFormGroup', + components: { + GlFormGroup, + }, + props: { + label: { + type: String, + required: true, + }, + labelFor: { + type: String, + required: false, + default: '', + }, + }, +}; +</script> +<template> + <div> + <gl-form-group + :label="label" + :label-for="labelFor" + label-cols-sm="2" + label-class="gl-mt-3" + label-align-sm="right" + > + <slot></slot> + </gl-form-group> + </div> +</template> diff --git a/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue b/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue index 1408312d3e4..a523dd3b391 100644 --- a/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue +++ b/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue @@ -1,6 +1,8 @@ <script> -import { GlButton, GlTableLite, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlButton, GlTableLite } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __ } from '~/locale'; +import { formatDate } from '~/lib/utils/datetime/date_format_utility'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; const DEFAULT_TD_CLASSES = 'gl-vertical-align-middle!'; @@ -12,7 +14,7 @@ export default { GlTableLite, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [glFeatureFlagsMixin()], i18n: { @@ -77,6 +79,11 @@ export default { safeHtmlConfig: { ADD_TAGS: ['use'], }, + methods: { + formatDate(dateString) { + return formatDate(new Date(dateString)); + }, + }, }; </script> <template> @@ -90,6 +97,14 @@ export default { <div v-safe-html:[$options.safeHtmlConfig]="preview"></div> </template> + <template #cell(starts_at)="{ item: { starts_at } }"> + {{ formatDate(starts_at) }} + </template> + + <template #cell(ends_at)="{ item: { ends_at } }"> + {{ formatDate(ends_at) }} + </template> + <template #cell(buttons)="{ item: { id, edit_path, disable_delete } }"> <gl-button icon="pencil" diff --git a/app/assets/javascripts/admin/broadcast_messages/constants.js b/app/assets/javascripts/admin/broadcast_messages/constants.js new file mode 100644 index 00000000000..6250d5a943d --- /dev/null +++ b/app/assets/javascripts/admin/broadcast_messages/constants.js @@ -0,0 +1,35 @@ +import { s__ } from '~/locale'; + +export const BROADCAST_MESSAGES_PATH = '/admin/broadcast_messages'; + +export const TYPE_BANNER = 'banner'; +export const TYPE_NOTIFICATION = 'notification'; + +export const TYPES = [ + { value: TYPE_BANNER, text: s__('BroadcastMessages|Banner') }, + { value: TYPE_NOTIFICATION, text: s__('BroadcastMessages|Notification') }, +]; + +export const THEMES = [ + { value: 'indigo', text: s__('BroadcastMessages|Indigo') }, + { value: 'light-indigo', text: s__('BroadcastMessages|Light Indigo') }, + { value: 'blue', text: s__('BroadcastMessages|Blue') }, + { value: 'light-blue', text: s__('BroadcastMessages|Light Blue') }, + { value: 'green', text: s__('BroadcastMessages|Green') }, + { value: 'light-green', text: s__('BroadcastMessages|Light Green') }, + { value: 'red', text: s__('BroadcastMessages|Red') }, + { value: 'light-red', text: s__('BroadcastMessages|Light Red') }, + { value: 'dark', text: s__('BroadcastMessages|Dark') }, + { value: 'light', text: s__('BroadcastMessages|Light') }, +]; + +export const NEW_BROADCAST_MESSAGE = { + message: '', + broadcastType: TYPES[0].value, + theme: THEMES[0].value, + dismissable: false, + targetPath: '', + targetAccessLevels: [], + startsAt: new Date(), + endsAt: new Date(), +}; diff --git a/app/assets/javascripts/admin/broadcast_messages/edit.js b/app/assets/javascripts/admin/broadcast_messages/edit.js new file mode 100644 index 00000000000..70a270f7a56 --- /dev/null +++ b/app/assets/javascripts/admin/broadcast_messages/edit.js @@ -0,0 +1,43 @@ +import Vue from 'vue'; +import MessageForm from './components/message_form.vue'; + +export default () => { + const el = document.querySelector('#js-broadcast-message'); + const { + id, + message, + broadcastType, + theme, + dismissable, + targetAccessLevels, + targetAccessLevelOptions, + targetPath, + startsAt, + endsAt, + } = el.dataset; + + return new Vue({ + el, + name: 'EditBroadcastMessage', + provide: { + targetAccessLevelOptions: JSON.parse(targetAccessLevelOptions), + }, + render(createElement) { + return createElement(MessageForm, { + props: { + broadcastMessage: { + id: parseInt(id, 10), + message, + broadcastType, + theme, + dismissable: dismissable === 'true', + targetAccessLevels: JSON.parse(targetAccessLevels), + targetPath, + startsAt: new Date(startsAt), + endsAt: new Date(endsAt), + }, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/admin/broadcast_messages/index.js b/app/assets/javascripts/admin/broadcast_messages/index.js index 81952d2033e..fd8b2aad4ec 100644 --- a/app/assets/javascripts/admin/broadcast_messages/index.js +++ b/app/assets/javascripts/admin/broadcast_messages/index.js @@ -3,11 +3,14 @@ import BroadcastMessagesBase from './components/base.vue'; export default () => { const el = document.querySelector('#js-broadcast-messages'); - const { page, messagesCount, messages } = el.dataset; + const { page, targetAccessLevelOptions, messagesCount, messages } = el.dataset; return new Vue({ el, name: 'BroadcastMessages', + provide: { + targetAccessLevelOptions: JSON.parse(targetAccessLevelOptions), + }, render(createElement) { return createElement(BroadcastMessagesBase, { props: { diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue index c0cac958a42..5229d4c9ae2 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_table.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue @@ -17,6 +17,7 @@ import { fetchPolicies } from '~/lib/graphql'; import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; import { s__, __, n__ } from '~/locale'; import AlertStatus from '~/vue_shared/alert_details/components/alert_status.vue'; +import { TOKEN_TYPE_ASSIGNEE } from '~/vue_shared/components/filtered_search_bar/constants'; import { tdClass, thClass, @@ -96,6 +97,7 @@ export default { sortable: true, }, ], + filterSearchTokens: [TOKEN_TYPE_ASSIGNEE], severityLabels: SEVERITY_LEVELS, statusTabs: ALERTS_STATUS_TABS, components: { @@ -294,9 +296,7 @@ export default { :status-tabs="$options.statusTabs" :track-views-options="$options.trackAlertListViewsOptions" :server-error-message="serverErrorMessage" - :filter-search-tokens="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ - 'assignee_username', - ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + :filter-search-tokens="$options.filterSearchTokens" filter-search-key="alerts" @page-changed="pageChanged" @tabs-changed="statusChanged" @@ -312,6 +312,7 @@ export default { <template #table> <gl-table class="alert-management-table" + data-qa-selector="alert_table_container" :items=" alerts ? alerts.list diff --git a/app/assets/javascripts/alerts_settings/components/alerts_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_form.vue index 388d925196b..a0d5cb7f4c3 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_form.vue @@ -83,7 +83,7 @@ export default { </p> <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"> + <gl-form-checkbox v-model="createIssueEnabled" data-qa-selector="create_incident_checkbox"> <span>{{ $options.i18n.createIncident.label }}</span> </gl-form-checkbox> </gl-form-group> diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue index 03bc4b825ae..65c3bc732ed 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue @@ -430,6 +430,7 @@ export default { v-model="integrationForm.type" :disabled="isSelectDisabled" class="gl-max-w-full" + data-qa-selector="integration_type_dropdown" :options="integrationTypesOptions" /> @@ -461,6 +462,7 @@ export default { v-model="integrationForm.name" type="text" :placeholder="$options.i18n.integrationFormSteps.nameIntegration.placeholder" + data-qa-selector="integration_name_field" @input="validateName" /> </gl-form-group> @@ -483,6 +485,7 @@ export default { v-model="integrationForm.active" :is-loading="loading" :label="$options.i18n.integrationFormSteps.nameIntegration.activeToggle" + data-qa-selector="active_toggle_container" class="gl-mt-4 gl-font-weight-normal" /> </gl-form-group> @@ -594,6 +597,7 @@ export default { category="secondary" class="gl-ml-3 js-no-auto-disable" data-testid="integration-form-test-and-submit" + data-qa-selector="save_and_create_alert_button" @click="submit(true)" > {{ $options.i18n.saveAndTestIntegration }} @@ -695,6 +699,7 @@ export default { :debounce="$options.JSON_VALIDATE_DELAY" rows="6" max-rows="10" + data-qa-selector="test_payload_field" @input="validateJson(false)" /> </gl-form-group> @@ -706,6 +711,7 @@ export default { data-testid="send-test-alert" variant="confirm" class="js-no-auto-disable" + data-qa-selector="send_test_alert_button" @click="isFormDirty ? null : sendTestAlert()" > {{ $options.i18n.send }} 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 bf456b6adaa..010cb5721a1 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue @@ -375,6 +375,7 @@ export default { category="secondary" variant="confirm" data-testid="add-integration-btn" + data-qa-selector="add_integration_button" class="gl-mt-3" @click="setFormVisibility(true)" > diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue index f06544f50c6..a688e2f497b 100644 --- a/app/assets/javascripts/cycle_analytics/components/base.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue @@ -5,9 +5,9 @@ import { getCookie, setCookie } from '~/lib/utils/common_utils'; import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue'; import { VSA_METRICS_GROUPS } from '~/analytics/shared/constants'; import { toYmd } from '~/analytics/shared/utils'; -import PathNavigation from '~/cycle_analytics/components/path_navigation.vue'; -import StageTable from '~/cycle_analytics/components/stage_table.vue'; -import ValueStreamFilters from '~/cycle_analytics/components/value_stream_filters.vue'; +import PathNavigation from '~/analytics/cycle_analytics/components/path_navigation.vue'; +import StageTable from '~/analytics/cycle_analytics/components/stage_table.vue'; +import ValueStreamFilters from '~/analytics/cycle_analytics/components/value_stream_filters.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue'; import { __ } from '~/locale'; import { SUMMARY_METRICS_REQUEST, METRICS_REQUESTS } from '../constants'; diff --git a/app/assets/javascripts/cycle_analytics/components/filter_bar.vue b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue index 0ad325a8523..54b632968e2 100644 --- a/app/assets/javascripts/cycle_analytics/components/filter_bar.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue @@ -1,12 +1,16 @@ <script> import { mapActions, mapState } from 'vuex'; import { - OPERATOR_IS_ONLY, - DEFAULT_NONE_ANY, + OPERATORS_IS, + OPTIONS_NONE_ANY, TOKEN_TITLE_ASSIGNEE, TOKEN_TITLE_AUTHOR, TOKEN_TITLE_LABEL, TOKEN_TITLE_MILESTONE, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import { @@ -14,7 +18,7 @@ import { processFilters, filterToQueryObject, } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; -import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue'; @@ -47,45 +51,45 @@ export default { { icon: 'clock', title: TOKEN_TITLE_MILESTONE, - type: 'milestone', + type: TOKEN_TYPE_MILESTONE, token: MilestoneToken, initialMilestones: this.milestonesData, unique: true, symbol: '%', - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, fetchMilestones: this.fetchMilestones, }, { icon: 'labels', title: TOKEN_TITLE_LABEL, - type: 'labels', + type: TOKEN_TYPE_LABEL, token: LabelToken, - defaultLabels: DEFAULT_NONE_ANY, + defaultLabels: OPTIONS_NONE_ANY, initialLabels: this.labelsData, unique: false, symbol: '~', - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, fetchLabels: this.fetchLabels, }, { icon: 'pencil', title: TOKEN_TITLE_AUTHOR, - type: 'author', - token: AuthorToken, - initialAuthors: this.authorsData, + type: TOKEN_TYPE_AUTHOR, + token: UserToken, + initialUsers: this.authorsData, unique: true, - operators: OPERATOR_IS_ONLY, - fetchAuthors: this.fetchAuthors, + operators: OPERATORS_IS, + fetchUsers: this.fetchAuthors, }, { icon: 'user', title: TOKEN_TITLE_ASSIGNEE, - type: 'assignees', - token: AuthorToken, - initialAuthors: this.assigneesData, + type: TOKEN_TYPE_ASSIGNEE, + token: UserToken, + initialUsers: this.assigneesData, unique: false, - operators: OPERATOR_IS_ONLY, - fetchAuthors: this.fetchAssignees, + operators: OPERATORS_IS, + fetchUsers: this.fetchAssignees, }, ]; }, @@ -108,14 +112,19 @@ export default { ]), initialFilterValue() { return prepareTokens({ - milestone: this.selectedMilestone, - author: this.selectedAuthor, - assignees: this.selectedAssigneeList, - labels: this.selectedLabelList, + [TOKEN_TYPE_MILESTONE]: this.selectedMilestone, + [TOKEN_TYPE_AUTHOR]: this.selectedAuthor, + [TOKEN_TYPE_ASSIGNEE]: this.selectedAssigneeList, + [TOKEN_TYPE_LABEL]: this.selectedLabelList, }); }, handleFilter(filters) { - const { labels, milestone, author, assignees } = processFilters(filters); + const { + [TOKEN_TYPE_LABEL]: labels, + [TOKEN_TYPE_MILESTONE]: milestone, + [TOKEN_TYPE_AUTHOR]: author, + [TOKEN_TYPE_ASSIGNEE]: assignees, + } = processFilters(filters); this.setFilters({ selectedAuthor: author ? author[0] : null, diff --git a/app/assets/javascripts/cycle_analytics/components/formatted_stage_count.vue b/app/assets/javascripts/analytics/cycle_analytics/components/formatted_stage_count.vue index b622b0441e2..b622b0441e2 100644 --- a/app/assets/javascripts/cycle_analytics/components/formatted_stage_count.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/formatted_stage_count.vue diff --git a/app/assets/javascripts/cycle_analytics/components/metric_tile.vue b/app/assets/javascripts/analytics/cycle_analytics/components/metric_tile.vue index a5c20b237b3..a5c20b237b3 100644 --- a/app/assets/javascripts/cycle_analytics/components/metric_tile.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/metric_tile.vue diff --git a/app/assets/javascripts/cycle_analytics/components/path_navigation.vue b/app/assets/javascripts/analytics/cycle_analytics/components/path_navigation.vue index 72a7659aac0..ac41bc4917c 100644 --- a/app/assets/javascripts/cycle_analytics/components/path_navigation.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/path_navigation.vue @@ -1,5 +1,6 @@ <script> -import { GlPath, GlPopover, GlSkeletonLoader, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlPath, GlPopover, GlSkeletonLoader } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import Tracking from '~/tracking'; import { OVERVIEW_STAGE_ID } from '../constants'; import FormattedStageCount from './formatted_stage_count.vue'; diff --git a/app/assets/javascripts/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue index f1fdffd4b72..78ac29426d9 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_table.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue @@ -8,7 +8,7 @@ import { GlTable, GlBadge, } from '@gitlab/ui'; -import FormattedStageCount from '~/cycle_analytics/components/formatted_stage_count.vue'; +import FormattedStageCount from '~/analytics/cycle_analytics/components/formatted_stage_count.vue'; import { __ } from '~/locale'; import Tracking from '~/tracking'; import { diff --git a/app/assets/javascripts/cycle_analytics/components/total_time.vue b/app/assets/javascripts/analytics/cycle_analytics/components/total_time.vue index 725952c3518..725952c3518 100644 --- a/app/assets/javascripts/cycle_analytics/components/total_time.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/total_time.vue diff --git a/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue b/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue index 17decb6b448..17decb6b448 100644 --- a/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue diff --git a/app/assets/javascripts/cycle_analytics/constants.js b/app/assets/javascripts/analytics/cycle_analytics/constants.js index 2758d686fb1..2758d686fb1 100644 --- a/app/assets/javascripts/cycle_analytics/constants.js +++ b/app/assets/javascripts/analytics/cycle_analytics/constants.js diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/analytics/cycle_analytics/index.js index 3da8696edeb..df161f7e563 100644 --- a/app/assets/javascripts/cycle_analytics/index.js +++ b/app/assets/javascripts/analytics/cycle_analytics/index.js @@ -3,7 +3,7 @@ import { extractFilterQueryParameters, extractPaginationQueryParameters, } from '~/analytics/shared/utils'; -import Translate from '../vue_shared/translate'; +import Translate from '~/vue_shared/translate'; import CycleAnalytics from './components/base.vue'; import createStore from './store'; import { buildCycleAnalyticsInitialData } from './utils'; diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/analytics/cycle_analytics/store/actions.js index 4a201e00582..4a201e00582 100644 --- a/app/assets/javascripts/cycle_analytics/store/actions.js +++ b/app/assets/javascripts/analytics/cycle_analytics/store/actions.js diff --git a/app/assets/javascripts/cycle_analytics/store/getters.js b/app/assets/javascripts/analytics/cycle_analytics/store/getters.js index 83068cabf0f..83068cabf0f 100644 --- a/app/assets/javascripts/cycle_analytics/store/getters.js +++ b/app/assets/javascripts/analytics/cycle_analytics/store/getters.js diff --git a/app/assets/javascripts/cycle_analytics/store/index.js b/app/assets/javascripts/analytics/cycle_analytics/store/index.js index 76e3e835016..76e3e835016 100644 --- a/app/assets/javascripts/cycle_analytics/store/index.js +++ b/app/assets/javascripts/analytics/cycle_analytics/store/index.js diff --git a/app/assets/javascripts/cycle_analytics/store/mutation_types.js b/app/assets/javascripts/analytics/cycle_analytics/store/mutation_types.js index 9376d81f317..9376d81f317 100644 --- a/app/assets/javascripts/cycle_analytics/store/mutation_types.js +++ b/app/assets/javascripts/analytics/cycle_analytics/store/mutation_types.js diff --git a/app/assets/javascripts/cycle_analytics/store/mutations.js b/app/assets/javascripts/analytics/cycle_analytics/store/mutations.js index 8567529caf2..8567529caf2 100644 --- a/app/assets/javascripts/cycle_analytics/store/mutations.js +++ b/app/assets/javascripts/analytics/cycle_analytics/store/mutations.js diff --git a/app/assets/javascripts/cycle_analytics/store/state.js b/app/assets/javascripts/analytics/cycle_analytics/store/state.js index 8d662333afa..00dd2e53883 100644 --- a/app/assets/javascripts/cycle_analytics/store/state.js +++ b/app/assets/javascripts/analytics/cycle_analytics/store/state.js @@ -1,7 +1,7 @@ import { PAGINATION_SORT_FIELD_END_EVENT, PAGINATION_SORT_DIRECTION_DESC, -} from '~/cycle_analytics/constants'; +} from '~/analytics/cycle_analytics/constants'; export default () => ({ id: null, diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/analytics/cycle_analytics/utils.js index 428bb11b950..428bb11b950 100644 --- a/app/assets/javascripts/cycle_analytics/utils.js +++ b/app/assets/javascripts/analytics/cycle_analytics/utils.js diff --git a/app/assets/javascripts/api/analytics_api.js b/app/assets/javascripts/api/analytics_api.js index 15457f28eff..66ed30130bb 100644 --- a/app/assets/javascripts/api/analytics_api.js +++ b/app/assets/javascripts/api/analytics_api.js @@ -7,6 +7,11 @@ const PROJECT_VSA_PATH_BASE = '/:request_path/-/analytics/value_stream_analytics const PROJECT_VSA_STAGES_PATH = `${PROJECT_VSA_PATH_BASE}/:value_stream_id/stages`; const PROJECT_VSA_STAGE_DATA_PATH = `${PROJECT_VSA_STAGES_PATH}/:stage_id`; +export const LEAD_TIME_METRIC_TYPE = 'lead_time'; +export const CYCLE_TIME_METRIC_TYPE = 'cycle_time'; +export const ISSUES_METRIC_TYPE = 'issues'; +export const DEPLOYS_METRIC_TYPE = 'deploys'; + export const METRIC_TYPE_SUMMARY = 'summary'; export const METRIC_TYPE_TIME_SUMMARY = 'time_summary'; diff --git a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql b/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql index 89a24d7891e..9777153999e 100644 --- a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql +++ b/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql @@ -10,6 +10,7 @@ query getJobArtifacts( project(fullPath: $projectPath) { id jobs( + withArtifacts: true statuses: [SUCCESS, FAILED] first: $firstPageSize last: $lastPageSize diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 9ab1d6bfd80..1855fb9ed8c 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -2,7 +2,7 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; -import { uniq } from 'lodash'; +import { uniq, escape } from 'lodash'; import { getEmojiScoreWithIntent } from '~/emoji/utils'; import { getCookie, setCookie, scrollToElement } from '~/lib/utils/common_utils'; import * as Emoji from '~/emoji'; @@ -149,7 +149,7 @@ export class AwardsHandler { let frequentlyUsedCatgegory = ''; if (frequentlyUsedEmojis.length > 0) { frequentlyUsedCatgegory = this.renderCategory('Frequently used', frequentlyUsedEmojis, { - menuListClass: 'frequent-emojis', + frequentEmojis: true, }); } @@ -228,9 +228,9 @@ export class AwardsHandler { renderCategory(name, emojiList, opts = {}) { return ` <h5 class="emoji-menu-title"> - ${name} + ${escape(name)} </h5> - <ul class="clearfix emoji-menu-list ${opts.menuListClass || ''}"> + <ul class="clearfix emoji-menu-list ${opts.frequentEmojis ? 'frequent-emojis' : ''}"> ${emojiList .map( (emojiName) => ` diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue index f68666f8a0c..c95c90d5daf 100644 --- a/app/assets/javascripts/badges/components/badge_form.vue +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -1,10 +1,12 @@ <script> -import { GlLoadingIcon, GlFormInput, GlFormGroup, GlButton, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlLoadingIcon, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui'; import { escape, debounce } from 'lodash'; import { mapActions, mapState } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { createAlert, VARIANT_INFO } from '~/flash'; import { s__, sprintf } from '~/locale'; import createEmptyBadge from '../empty_badge'; +import { PLACEHOLDERS } from '../constants'; import Badge from './badge.vue'; const badgePreviewDelayInMilliseconds = 1500; @@ -19,7 +21,7 @@ export default { GlFormGroup, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { isEditing: { @@ -49,9 +51,9 @@ export default { return this.badgeInAddForm; }, helpText() { - const placeholders = ['project_path', 'project_id', 'default_branch', 'commit_sha'] - .map((placeholder) => `<code>%{${placeholder}}</code>`) - .join(', '); + const placeholders = PLACEHOLDERS.map((placeholder) => `<code>%{${placeholder}}</code>`).join( + ', ', + ); return sprintf( s__('Badges|Supported %{docsLinkStart}variables%{docsLinkEnd}: %{placeholders}'), { diff --git a/app/assets/javascripts/badges/constants.js b/app/assets/javascripts/badges/constants.js index 8fbe3db5ef1..709436abca6 100644 --- a/app/assets/javascripts/badges/constants.js +++ b/app/assets/javascripts/badges/constants.js @@ -1,2 +1,10 @@ export const GROUP_BADGE = 'group'; export const PROJECT_BADGE = 'project'; +export const PLACEHOLDERS = [ + 'project_path', + 'project_title', + 'project_name', + 'project_id', + 'default_branch', + 'commit_sha', +]; diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue index e5408d0734a..5bb310afac7 100644 --- a/app/assets/javascripts/batch_comments/components/draft_note.vue +++ b/app/assets/javascripts/batch_comments/components/draft_note.vue @@ -1,6 +1,7 @@ <script> -import { GlButton, GlSafeHtmlDirective, GlBadge } from '@gitlab/ui'; +import { GlButton, GlBadge } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import NoteableNote from '~/notes/components/noteable_note.vue'; import PublishButton from './publish_button.vue'; @@ -13,7 +14,7 @@ export default { GlBadge, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [glFeatureFlagMixin()], props: { @@ -84,32 +85,25 @@ export default { }; </script> <template> - <article - class="draft-note-component note-wrapper" - @mouseenter="handleMouseEnter(draft)" - @mouseleave="handleMouseLeave(draft)" + <noteable-note + :note="draft" + :line="line" + :discussion-root="true" + :class="{ 'gl-mb-0!': glFeatures.mrReviewSubmitComment }" + class="draft-note-component draft-note" + @handleEdit="handleEditing" + @cancelForm="handleNotEditing" + @updateSuccess="handleNotEditing" + @handleDeleteNote="deleteDraft" + @handleUpdateNote="update" + @toggleResolveStatus="toggleResolveDiscussion(draft.id)" + @mouseenter.native="handleMouseEnter(draft)" + @mouseleave.native="handleMouseLeave(draft)" > - <ul class="notes draft-notes"> - <noteable-note - :note="draft" - :line="line" - :discussion-root="true" - :class="{ 'gl-mb-0!': glFeatures.mrReviewSubmitComment }" - class="draft-note" - @handleEdit="handleEditing" - @cancelForm="handleNotEditing" - @updateSuccess="handleNotEditing" - @handleDeleteNote="deleteDraft" - @handleUpdateNote="update" - @toggleResolveStatus="toggleResolveDiscussion(draft.id)" - > - <template #note-header-info> - <gl-badge variant="warning" class="gl-mr-2">{{ __('Pending') }}</gl-badge> - </template> - </noteable-note> - </ul> - - <template v-if="!isEditingDraft"> + <template #note-header-info> + <gl-badge variant="warning" class="gl-mr-2">{{ __('Pending') }}</gl-badge> + </template> + <template v-if="!isEditingDraft" #after-note-body> <div v-if="draftCommands" v-safe-html:[$options.safeHtmlConfig]="draftCommands" @@ -133,5 +127,5 @@ export default { </gl-button> </p> </template> - </article> + </noteable-note> </template> diff --git a/app/assets/javascripts/behaviors/copy_code.js b/app/assets/javascripts/behaviors/copy_code.js index ae186aba32d..0c81ae63f21 100644 --- a/app/assets/javascripts/behaviors/copy_code.js +++ b/app/assets/javascripts/behaviors/copy_code.js @@ -7,7 +7,10 @@ class CopyCodeButton extends HTMLElement { connectedCallback() { this.for = uniqueId('code-'); - this.parentNode.querySelector('pre').setAttribute('id', this.for); + const target = this.parentNode.querySelector('pre'); + if (!target) return; + + target.setAttribute('id', this.for); this.appendChild(this.createButton()); } diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js index 30160248a77..220064e6673 100644 --- a/app/assets/javascripts/behaviors/index.js +++ b/app/assets/javascripts/behaviors/index.js @@ -1,6 +1,5 @@ import $ from 'jquery'; import './autosize'; -import './markdown/render_gfm'; import initCollapseSidebarOnWindowResize from './collapse_sidebar_on_window_resize'; import initCopyToClipboard from './copy_to_clipboard'; import installGlEmojiElement from './gl_emoji'; diff --git a/app/assets/javascripts/behaviors/markdown/init_gfm.js b/app/assets/javascripts/behaviors/markdown/init_gfm.js new file mode 100644 index 00000000000..d9c7cee50da --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/init_gfm.js @@ -0,0 +1,13 @@ +import $ from 'jquery'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; + +$.fn.renderGFM = function plugin() { + this.get().forEach(renderGFM); + return this; +}; +requestIdleCallback( + () => { + renderGFM(document.body); + }, + { timeout: 500 }, +); diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js index a08cf48c327..2eab5b84e3e 100644 --- a/app/assets/javascripts/behaviors/markdown/render_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -1,45 +1,52 @@ -import $ from 'jquery'; import syntaxHighlight from '~/syntax_highlight'; import highlightCurrentUser from './highlight_current_user'; import { renderKroki } from './render_kroki'; import renderMath from './render_math'; import renderSandboxedMermaid from './render_sandboxed_mermaid'; import renderMetrics from './render_metrics'; +import renderObservability from './render_observability'; import { renderJSONTable } from './render_json_table'; -// Render GitLab flavoured Markdown -// -// Delegates to syntax highlight and render math & mermaid diagrams. -// -$.fn.renderGFM = function renderGFM() { - syntaxHighlight(this.find('.js-syntax-highlight').get()); - renderKroki(this.find('.js-render-kroki[hidden]').get()); - renderMath(this.find('.js-render-math')); - renderSandboxedMermaid(this.find('.js-render-mermaid').get()); - renderJSONTable( - Array.from(this.find('[lang="json"][data-lang-params="table"]').get()).map((e) => e.parentNode), - ); - - highlightCurrentUser(this.find('.gfm-project_member').get()); +function initPopovers(elements) { + if (!elements.length) return; + import(/* webpackChunkName: 'IssuablePopoverBundle' */ '~/issuable/popover') + .then(({ default: initIssuablePopovers }) => { + initIssuablePopovers(elements); + }) + .catch(() => {}); +} - const issuablePopoverElements = this.find('.gfm-issue, .gfm-merge_request').get(); - if (issuablePopoverElements.length) { - import(/* webpackChunkName: 'IssuablePopoverBundle' */ '~/issuable/popover') - .then(({ default: initIssuablePopovers }) => { - initIssuablePopovers(issuablePopoverElements); - }) - .catch(() => {}); - } - - renderMetrics(this.find('.js-render-metrics').get()); - return this; -}; +// Render GitLab flavoured Markdown +export function renderGFM(element) { + const [ + highlightEls, + krokiEls, + mathEls, + mermaidEls, + tableEls, + userEls, + popoverEls, + metricsEls, + observabilityEls, + ] = [ + '.js-syntax-highlight', + '.js-render-kroki[hidden]', + '.js-render-math', + '.js-render-mermaid', + '[lang="json"][data-lang-params="table"]', + '.gfm-project_member', + '.gfm-issue, .gfm-merge_request', + '.js-render-metrics', + '.js-render-observability', + ].map((selector) => Array.from(element.querySelectorAll(selector))); -$(() => { - window.requestIdleCallback( - () => { - $('body').renderGFM(); - }, - { timeout: 500 }, - ); -}); + syntaxHighlight(highlightEls); + renderKroki(krokiEls); + renderMath(mathEls); + renderSandboxedMermaid(mermaidEls); + renderJSONTable(tableEls.map((e) => e.parentNode)); + highlightCurrentUser(userEls); + renderMetrics(metricsEls); + renderObservability(observabilityEls); + initPopovers(popoverEls); +} diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js index ac41af4df7a..7852a909160 100644 --- a/app/assets/javascripts/behaviors/markdown/render_math.js +++ b/app/assets/javascripts/behaviors/markdown/render_math.js @@ -175,14 +175,14 @@ class SafeMathRenderer { } } -export default function renderMath($els) { - if (!$els.length) return; +export default function renderMath(elements) { + if (!elements.length) return; Promise.all([ import(/* webpackChunkName: 'katex' */ 'katex'), import(/* webpackChunkName: 'katex' */ 'katex/dist/katex.min.css'), ]) .then(([katex]) => { - const renderer = new SafeMathRenderer($els.get(), katex); + const renderer = new SafeMathRenderer(elements, katex); renderer.render(); renderer.attachEvents(); }) diff --git a/app/assets/javascripts/behaviors/markdown/render_observability.js b/app/assets/javascripts/behaviors/markdown/render_observability.js new file mode 100644 index 00000000000..704d85cf22e --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/render_observability.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import { darkModeEnabled } from '~/lib/utils/color_utils'; +import { setUrlParams } from '~/lib/utils/url_utility'; + +export function getFrameSrc(url) { + return `${setUrlParams({ theme: darkModeEnabled() ? 'dark' : 'light' }, url)}&kiosk`; +} + +const mountVueComponent = (element) => { + const url = [element.dataset.frameUrl]; + + return new Vue({ + el: element, + render(h) { + return h('iframe', { + style: { + height: '366px', + width: '768px', + }, + attrs: { + src: getFrameSrc(url), + frameBorder: '0', + }, + }); + }, + }); +}; + +export default function renderObservability(elements) { + elements.forEach((element) => { + mountVueComponent(element); + }); +} diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js index 68f5180cc03..86a05f24dfc 100644 --- a/app/assets/javascripts/behaviors/preview_markdown.js +++ b/app/assets/javascripts/behaviors/preview_markdown.js @@ -4,6 +4,7 @@ import $ from 'jquery'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; +import '~/behaviors/markdown/init_gfm'; // MarkdownPreview // diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index 97ba9e15c0f..64297da39cd 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js @@ -3,7 +3,7 @@ import ClipboardJS from 'clipboard'; import Mousetrap from 'mousetrap'; import { getSelectedFragment } from '~/lib/utils/common_utils'; import { isElementVisible } from '~/lib/utils/dom_utils'; -import { DEBOUNCE_DROPDOWN_DELAY } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; +import { DEBOUNCE_DROPDOWN_DELAY } from '~/sidebar/components/labels/labels_select_widget/constants'; import toast from '~/vue_shared/plugins/global_toast'; import { s__ } from '~/locale'; import Sidebar from '~/right_sidebar'; diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue index 716321430d2..361d736f740 100644 --- a/app/assets/javascripts/blob/components/blob_header.vue +++ b/app/assets/javascripts/blob/components/blob_header.vue @@ -1,5 +1,5 @@ <script> -import DefaultActions from './blob_header_default_actions.vue'; +import DefaultActions from 'jh_else_ce/blob/components/blob_header_default_actions.vue'; import BlobFilepath from './blob_header_filepath.vue'; import ViewerSwitcher from './blob_header_viewer_switcher.vue'; import { SIMPLE_BLOB_VIEWER } from './constants'; diff --git a/app/assets/javascripts/blob/openapi/index.js b/app/assets/javascripts/blob/openapi/index.js index 24a54358de5..8cfdc00bb40 100644 --- a/app/assets/javascripts/blob/openapi/index.js +++ b/app/assets/javascripts/blob/openapi/index.js @@ -5,7 +5,7 @@ const createSandbox = () => { const iframeEl = document.createElement('iframe'); setAttributes(iframeEl, { src: '/-/sandbox/swagger', - sandbox: 'allow-scripts allow-popups', + sandbox: 'allow-scripts allow-popups allow-forms', frameBorder: 0, width: '100%', // The height will be adjusted dynamically. diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index 8d323c335d3..439c4258805 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import '~/behaviors/markdown/render_gfm'; +import '~/behaviors/markdown/init_gfm'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; import { diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index 4741dd53708..509d399273d 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -66,7 +66,7 @@ export default () => { }) .catch((e) => createAlert({ - message: e, + message: e.message, }), ); diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index 97d8b206307..46b3f16df77 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -9,6 +9,7 @@ import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown'; import { insertFinalNewline } from '~/lib/utils/text_utility'; import TemplateSelectorMediator from '../blob/file_template_mediator'; import { BLOB_EDITOR_ERROR, BLOB_PREVIEW_ERROR } from './constants'; +import '~/behaviors/markdown/init_gfm'; export default class EditBlob { // The options object has: diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 150378f7a7d..ca86894ca40 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -1,8 +1,10 @@ <script> import { GlAlert } from '@gitlab/ui'; +import { breakpoints } from '@gitlab/ui/dist/utils'; import { sortBy, throttle } from 'lodash'; import Draggable from 'vuedraggable'; import { mapState, mapGetters, mapActions } from 'vuex'; +import { contentTop } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import { formatBoardLists } from 'ee_else_ce/boards/boards_util'; import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue'; @@ -114,6 +116,8 @@ export default { group: 'boards-list', tag: 'div', value: this.boardListsToUse, + delay: 100, + delayOnTouchOnly: true, }; return this.canDragColumns ? options : {}; @@ -142,7 +146,11 @@ export default { el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' }); }, setBoardHeight() { - this.boardHeight = `${window.innerHeight - this.$el.getBoundingClientRect().top}px`; + if (window.innerWidth < breakpoints.md) { + this.boardHeight = `${window.innerHeight - contentTop()}px`; + } else { + this.boardHeight = `${window.innerHeight - this.$el.getBoundingClientRect().top}px`; + } }, }, }; diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index 00b4e6c96a9..392a73b5859 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -14,8 +14,8 @@ import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue import SidebarSeverity from '~/sidebar/components/severity/sidebar_severity.vue'; import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; -import SidebarLabelsWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; -import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; +import SidebarLabelsWidget from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue'; +import { LabelType } from '~/sidebar/components/labels/labels_select_widget/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { @@ -32,10 +32,12 @@ export default { SidebarTodoWidget, SidebarSeverity, MountingPortal, + SidebarHealthStatusWidget: () => + import('ee_component/sidebar/components/health_status/sidebar_health_status_widget.vue'), + SidebarIterationWidget: () => + import('ee_component/sidebar/components/iteration/sidebar_iteration_widget.vue'), SidebarWeightWidget: () => import('ee_component/sidebar/components/weight/sidebar_weight_widget.vue'), - IterationSidebarDropdownWidget: () => - import('ee_component/sidebar/components/iteration_sidebar_dropdown_widget.vue'), }, mixins: [glFeatureFlagMixin()], inject: { @@ -51,6 +53,9 @@ export default { weightFeatureAvailable: { default: false, }, + healthStatusFeatureAvailable: { + default: false, + }, allowLabelEdit: { default: false, }, @@ -115,6 +120,7 @@ export default { 'setActiveItemConfidential', 'setActiveBoardItemLabels', 'setActiveItemWeight', + 'setActiveItemHealthStatus', ]), handleClose() { this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType }); @@ -143,7 +149,7 @@ export default { <gl-drawer v-bind="$attrs" :open="showSidebar" - class="boards-sidebar gl-absolute" + class="boards-sidebar" variant="sidebar" @close="handleClose" > @@ -187,7 +193,7 @@ export default { :issuable-type="issuableType" data-testid="sidebar-milestones" /> - <iteration-sidebar-dropdown-widget + <sidebar-iteration-widget v-if="iterationFeatureAvailable && !isIncidentSidebar" :iid="activeBoardItem.iid" :workspace-path="projectPathForActiveIssue" @@ -236,6 +242,13 @@ export default { :issuable-type="issuableType" @weightUpdated="setActiveItemWeight($event)" /> + <sidebar-health-status-widget + v-if="healthStatusFeatureAvailable" + :iid="activeBoardItem.iid" + :full-path="fullPath" + :issuable-type="issuableType" + @statusUpdated="setActiveItemHealthStatus($event)" + /> <sidebar-confidentiality-widget :iid="activeBoardItem.iid" :full-path="fullPath" diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 816b22e4dc6..215691c7ba2 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -133,6 +133,8 @@ export default { 'ghost-class': 'board-card-drag-active', 'data-list-id': this.list.id, value: this.boardItems, + delay: 100, + delayOnTouchOnly: true, }; return this.canMoveIssue ? options : {}; @@ -317,7 +319,7 @@ export default { > <!-- TODO: remove the condition when https://gitlab.com/gitlab-org/gitlab/-/issues/377862 is resolved --> <board-card-move-to-position - v-if="!isEpicBoard" + v-if="!isEpicBoard && !disabled" :item="item" :index="index" :list="list" diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index eaf3facb450..4f90d77c0be 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -237,7 +237,7 @@ export default { :text="board.name" @show="loadBoards" > - <p class="gl-new-dropdown-header-top" @mousedown.prevent> + <p class="gl-dropdown-header-top" @mousedown.prevent> {{ s__('IssueBoards|Switch board') }} </p> <gl-search-box-by-type ref="searchBox" v-model="filterTerm" class="m-2" /> diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue index 605e11d1590..bc68c2e0e99 100644 --- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue @@ -12,8 +12,8 @@ import { TYPE_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import { - OPERATOR_IS_AND_IS_NOT, - OPERATOR_IS_ONLY, + OPERATORS_IS_NOT, + OPERATORS_IS, TOKEN_TITLE_ASSIGNEE, TOKEN_TITLE_AUTHOR, TOKEN_TITLE_CONFIDENTIAL, @@ -31,7 +31,7 @@ import { TOKEN_TYPE_RELEASE, TOKEN_TYPE_TYPE, } from '~/vue_shared/components/filtered_search_bar/constants'; -import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; @@ -60,7 +60,7 @@ export default { tokensCE() { const { issue, incident } = this.$options.i18n; const { types } = this.$options; - const { fetchAuthors, fetchLabels } = issueBoardFilters( + const { fetchUsers, fetchLabels } = issueBoardFilters( this.$apollo, this.fullPath, this.boardType, @@ -71,28 +71,28 @@ export default { icon: 'user', title: TOKEN_TITLE_ASSIGNEE, type: TOKEN_TYPE_ASSIGNEE, - operators: OPERATOR_IS_AND_IS_NOT, - token: AuthorToken, + operators: OPERATORS_IS_NOT, + token: UserToken, unique: true, - fetchAuthors, - preloadedAuthors: this.preloadedAuthors(), + fetchUsers, + preloadedUsers: this.preloadedUsers(), }, { icon: 'pencil', title: TOKEN_TITLE_AUTHOR, type: TOKEN_TYPE_AUTHOR, - operators: OPERATOR_IS_AND_IS_NOT, + operators: OPERATORS_IS_NOT, symbol: '@', - token: AuthorToken, + token: UserToken, unique: true, - fetchAuthors, - preloadedAuthors: this.preloadedAuthors(), + fetchUsers, + preloadedUsers: this.preloadedUsers(), }, { icon: 'labels', title: TOKEN_TITLE_LABEL, type: TOKEN_TYPE_LABEL, - operators: OPERATOR_IS_AND_IS_NOT, + operators: OPERATORS_IS_NOT, token: LabelToken, unique: false, symbol: '~', @@ -128,7 +128,7 @@ export default { title: TOKEN_TITLE_CONFIDENTIAL, unique: true, token: GlFilteredSearchToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, options: [ { icon: 'eye-slash', value: 'yes', title: __('Yes') }, { icon: 'eye', value: 'no', title: __('No') }, @@ -186,7 +186,7 @@ export default { }, methods: { ...mapActions(['fetchMilestones']), - preloadedAuthors() { + preloadedUsers() { return gon?.current_user_id ? [ { diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue index a35b3f14be4..b70294c9db3 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue @@ -6,7 +6,7 @@ export default { components: { IssuableTimeTracker, }, - inject: ['timeTrackingLimitToHours'], + inject: ['timeTrackingLimitToHours', 'canUpdate'], computed: { ...mapGetters(['activeBoardItem']), initialTimeTracking() { @@ -34,5 +34,6 @@ export default { :limit-to-hours="timeTrackingLimitToHours" :initial-time-tracking="initialTimeTracking" :show-collapsed="false" + :can-add-time-entries="canUpdate" /> </template> diff --git a/app/assets/javascripts/boards/issue_board_filters.js b/app/assets/javascripts/boards/issue_board_filters.js index 699d7e12de4..4bfd92fb748 100644 --- a/app/assets/javascripts/boards/issue_board_filters.js +++ b/app/assets/javascripts/boards/issue_board_filters.js @@ -14,13 +14,13 @@ export default function issueBoardFilters(apollo, fullPath, boardType) { return isGroupBoard ? groupBoardMembers : projectBoardMembers; }; - const fetchAuthors = (authorsSearchTerm) => { + const fetchUsers = (usersSearchTerm) => { return apollo .query({ query: boardAssigneesQuery(), variables: { fullPath, - search: authorsSearchTerm, + search: usersSearchTerm, }, }) .then(({ data }) => data.workspace?.assignees.nodes.map(({ user }) => user)); @@ -42,6 +42,6 @@ export default function issueBoardFilters(apollo, fullPath, boardType) { return { fetchLabels, - fetchAuthors, + fetchUsers, }; } diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index e5437690fd4..07b127d86e2 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -928,4 +928,5 @@ export default { // EE action needs CE empty equivalent setActiveItemWeight: () => {}, + setActiveItemHealthStatus: () => {}, }; diff --git a/app/assets/javascripts/branches/components/sort_dropdown.vue b/app/assets/javascripts/branches/components/sort_dropdown.vue index 5f782b5e652..263efcaa788 100644 --- a/app/assets/javascripts/branches/components/sort_dropdown.vue +++ b/app/assets/javascripts/branches/components/sort_dropdown.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem, GlSearchBoxByClick } from '@gitlab/ui'; +import { GlCollapsibleListbox, GlSearchBoxByClick } from '@gitlab/ui'; import { mergeUrlParams, visitUrl, getParameterValues } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; @@ -10,8 +10,7 @@ export default { searchPlaceholder: s__('Branches|Filter by branch name'), }, components: { - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, GlSearchBoxByClick, }, inject: ['projectBranchesFilteredPath', 'sortOptions', 'mode'], @@ -28,6 +27,9 @@ export default { selectedSortMethodName() { return this.sortOptions[this.selectedKey]; }, + listboxItems() { + return Object.entries(this.sortOptions).map(([value, text]) => ({ value, text })); + }, }, created() { const sortValue = getParameterValues('sort'); @@ -42,9 +44,6 @@ export default { } }, methods: { - isSortMethodSelected(sortKey) { - return sortKey === this.selectedKey; - }, visitUrlFromOption(sortKey) { this.selectedKey = sortKey; const urlParams = {}; @@ -70,20 +69,15 @@ export default { data-testid="branch-search" @submit="visitUrlFromOption(selectedKey)" /> - <gl-dropdown + + <gl-collapsible-listbox v-if="shouldShowDropdown" - :text="selectedSortMethodName" + v-model="selectedKey" + :items="listboxItems" + :toggle-text="selectedSortMethodName" class="gl-mr-3" data-testid="branches-dropdown" - > - <gl-dropdown-item - v-for="(value, key) in sortOptions" - :key="key" - :is-checked="isSortMethodSelected(key)" - is-check-item - @click="visitUrlFromOption(key)" - >{{ value }}</gl-dropdown-item - > - </gl-dropdown> + @select="visitUrlFromOption(selectedKey)" + /> </div> </template> diff --git a/app/assets/javascripts/branches/init_new_branch_ref_selector.js b/app/assets/javascripts/branches/init_new_branch_ref_selector.js new file mode 100644 index 00000000000..aad3fbb9982 --- /dev/null +++ b/app/assets/javascripts/branches/init_new_branch_ref_selector.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import RefSelector from '~/ref/components/ref_selector.vue'; + +export default function initNewBranchRefSelector() { + const el = document.querySelector('.js-new-branch-ref-selector'); + + if (!el) { + return false; + } + + const { projectId, defaultBranchName, hiddenInputName } = el.dataset; + + return new Vue({ + el, + render(createComponent) { + return createComponent(RefSelector, { + props: { + value: defaultBranchName, + name: hiddenInputName, + projectId, + }, + }); + }, + }); +} diff --git a/app/assets/javascripts/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci/ci_lint/components/ci_lint.vue index 8db4cba529f..49a314e067c 100644 --- a/app/assets/javascripts/ci_lint/components/ci_lint.vue +++ b/app/assets/javascripts/ci/ci_lint/components/ci_lint.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlFormCheckbox, GlIcon, GlLink, GlAlert } from '@gitlab/ui'; -import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue'; -import lintCiMutation from '~/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql'; +import CiLintResults from '~/ci/pipeline_editor/components/lint/ci_lint_results.vue'; +import lintCiMutation from '~/ci/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql'; import SourceEditor from '~/vue_shared/components/source_editor.vue'; export default { diff --git a/app/assets/javascripts/ci_lint/index.js b/app/assets/javascripts/ci/ci_lint/index.js index 274aab45deb..382059eb17e 100644 --- a/app/assets/javascripts/ci_lint/index.js +++ b/app/assets/javascripts/ci/ci_lint/index.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import { resolvers } from '~/pipeline_editor/graphql/resolvers'; +import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers'; import CiLint from './components/ci_lint.vue'; diff --git a/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue b/app/assets/javascripts/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue index 7b33d98bca0..7b33d98bca0 100644 --- a/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue diff --git a/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/constants.js b/app/assets/javascripts/ci/pipeline_editor/components/code_snippet_alert/constants.js index e4fd423249b..e4fd423249b 100644 --- a/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/constants.js +++ b/app/assets/javascripts/ci/pipeline_editor/components/code_snippet_alert/constants.js diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue index 4775836fcc6..4775836fcc6 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_section.vue index 9cbf60b1c8f..9cbf60b1c8f 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_section.vue diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue index 0b57433e894..0b57433e894 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/getting_started_card.vue index d2682cf6326..d2682cf6326 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/getting_started_card.vue diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue index bc9203b9c5b..bc9203b9c5b 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue index aeeb52319d2..aeeb52319d2 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue index 375db7f3054..375db7f3054 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/ui/demo_job_pill.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/ui/demo_job_pill.vue index 049504181c4..049504181c4 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/ui/demo_job_pill.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/ui/demo_job_pill.vue diff --git a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue index 42e2d34fa3a..42e2d34fa3a 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue diff --git a/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue index 189690ce2c3..201fba837e2 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue @@ -43,9 +43,7 @@ export default { </script> <template> - <div - class="gl-bg-gray-10 gl-display-flex gl-p-3 gl-gap-3 gl-border-solid gl-border-gray-100 gl-border-1" - > + <div class="gl-display-flex gl-p-3 gl-gap-3 gl-border-solid gl-border-gray-100 gl-border-1"> <gl-button :href="$options.TEMPLATE_REPOSITORY_URL" size="small" diff --git a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue index 255e3cb31f1..255e3cb31f1 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue index 1f8ddae3696..ef9acc1f8f1 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue @@ -16,11 +16,11 @@ import { BRANCH_PAGINATION_LIMIT, BRANCH_SEARCH_DEBOUNCE, DEFAULT_FAILURE, -} from '~/pipeline_editor/constants'; -import updateCurrentBranchMutation from '~/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql'; -import getAvailableBranchesQuery from '~/pipeline_editor/graphql/queries/available_branches.query.graphql'; -import getCurrentBranch from '~/pipeline_editor/graphql/queries/client/current_branch.query.graphql'; -import getLastCommitBranch from '~/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql'; +} from '~/ci/pipeline_editor/constants'; +import updateCurrentBranchMutation from '~/ci/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql'; +import getAvailableBranchesQuery from '~/ci/pipeline_editor/graphql/queries/available_branches.query.graphql'; +import getCurrentBranch from '~/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql'; +import getLastCommitBranch from '~/ci/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql'; export default { i18n: { diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue index 8e95fad1e48..84c29e48114 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue @@ -1,6 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; -import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.query.graphql'; +import getAppStatus from '~/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql'; import { EDITOR_APP_STATUS_EMPTY, EDITOR_APP_STATUS_LOADING } from '../../constants'; import FileTreePopover from '../popovers/file_tree_popover.vue'; import BranchSwitcher from './branch_switcher.vue'; diff --git a/app/assets/javascripts/pipeline_editor/components/file_tree/container.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_tree/container.vue index 280cd729a43..280cd729a43 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_tree/container.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/file_tree/container.vue diff --git a/app/assets/javascripts/pipeline_editor/components/file_tree/file_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_tree/file_item.vue index 786d483b5b9..786d483b5b9 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_tree/file_item.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/file_tree/file_item.vue diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue index ec6ee52b6b2..ec6ee52b6b2 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue index feadc60a22a..feadc60a22a 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue index 137dfca68d6..372f04075ab 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue @@ -3,8 +3,8 @@ import { GlButton, GlIcon, GlLink, GlLoadingIcon, GlSprintf, GlTooltipDirective import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { truncateSha } from '~/lib/utils/text_utility'; import { s__ } from '~/locale'; -import getPipelineQuery from '~/pipeline_editor/graphql/queries/pipeline.query.graphql'; -import getPipelineEtag from '~/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql'; +import getPipelineQuery from '~/ci/pipeline_editor/graphql/queries/pipeline.query.graphql'; +import getPipelineEtag from '~/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql'; import { getQueryHeaders, toggleQueryPollingByVisibility, diff --git a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue index 610a570c4ce..84c0eef441f 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; -import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.query.graphql'; +import getAppStatus from '~/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { EDITOR_APP_STATUS_EMPTY, diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results.vue index 0f19b9386e6..0f19b9386e6 100644 --- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results.vue diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_param.vue b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results_param.vue index 49225a7cac7..49225a7cac7 100644 --- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_param.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results_param.vue diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_value.vue b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results_value.vue index ef2be2a5fba..ef2be2a5fba 100644 --- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_value.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results_value.vue diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_warnings.vue b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_warnings.vue index ac0332cb0bd..ac0332cb0bd 100644 --- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_warnings.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_warnings.vue diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue index ed5466ff99c..ed5466ff99c 100644 --- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue diff --git a/app/assets/javascripts/pipeline_editor/components/popovers/file_tree_popover.vue b/app/assets/javascripts/ci/pipeline_editor/components/popovers/file_tree_popover.vue index efa6a54c638..efa6a54c638 100644 --- a/app/assets/javascripts/pipeline_editor/components/popovers/file_tree_popover.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/popovers/file_tree_popover.vue diff --git a/app/assets/javascripts/pipeline_editor/components/popovers/validate_pipeline_popover.vue b/app/assets/javascripts/ci/pipeline_editor/components/popovers/validate_pipeline_popover.vue index 4730a521227..4730a521227 100644 --- a/app/assets/javascripts/pipeline_editor/components/popovers/validate_pipeline_popover.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/popovers/validate_pipeline_popover.vue diff --git a/app/assets/javascripts/pipeline_editor/components/popovers/walkthrough_popover.vue b/app/assets/javascripts/ci/pipeline_editor/components/popovers/walkthrough_popover.vue index c636d8b8e34..c636d8b8e34 100644 --- a/app/assets/javascripts/pipeline_editor/components/popovers/walkthrough_popover.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/popovers/walkthrough_popover.vue diff --git a/app/assets/javascripts/pipeline_editor/components/ui/confirm_unsaved_changes_dialog.vue b/app/assets/javascripts/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog.vue index bc076fbe349..bc076fbe349 100644 --- a/app/assets/javascripts/pipeline_editor/components/ui/confirm_unsaved_changes_dialog.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog.vue diff --git a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue b/app/assets/javascripts/ci/pipeline_editor/components/ui/editor_tab.vue index 65f399d1912..22b82f2e96f 100644 --- a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/ui/editor_tab.vue @@ -41,7 +41,9 @@ import { __, s__ } from '~/locale'; export default { i18n: { - invalid: __('Your CI/CD configuration syntax is invalid. View Lint tab for more details.'), + invalid: __( + 'Your CI/CD configuration syntax is invalid. Select the Validate tab for more details.', + ), unavailable: __( "We're experiencing difficulties and this tab content is currently unavailable.", ), diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue b/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue index 7d2b9cd3d42..d7b8e7151d9 100644 --- a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlSprintf } from '@gitlab/ui'; import { __ } from '~/locale'; -import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; +import PipelineEditorFileNav from '~/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; export default { components: { diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue b/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_messages.vue index c72cff4c6f8..c72cff4c6f8 100644 --- a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_messages.vue diff --git a/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue b/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue index 83fcab4b343..83fcab4b343 100644 --- a/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/ci/pipeline_editor/constants.js index dd25c4d433b..dd25c4d433b 100644 --- a/app/assets/javascripts/pipeline_editor/constants.js +++ b/app/assets/javascripts/ci/pipeline_editor/constants.js diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql index 2d42ebb6ac3..2d42ebb6ac3 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_app_status.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_app_status.mutation.graphql index 7487e328668..7487e328668 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_app_status.mutation.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_app_status.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql index b722c147f5f..b722c147f5f 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_last_commit_branch.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_last_commit_branch.mutation.graphql index 9561312f2b6..9561312f2b6 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_last_commit_branch.mutation.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_last_commit_branch.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql index 9025f00b343..9025f00b343 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql index 3495ca51283..3495ca51283 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/available_branches.query.graphql index 359b4a846c7..359b4a846c7 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.query.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/available_branches.query.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/blob_content.query.graphql index 5928d90f7c4..5928d90f7c4 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.query.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/blob_content.query.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/ci_config.query.graphql index 5354ed7c2d5..5354ed7c2d5 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.query.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/ci_config.query.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql index 0df8cafa3cb..0df8cafa3cb 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.query.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql index 1f4f9d26f24..1f4f9d26f24 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.query.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql index a83129759de..a83129759de 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql index 8df6e74a5d9..8df6e74a5d9 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/get_starter_template.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/get_starter_template.query.graphql index a34c8f365f4..a34c8f365f4 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/get_starter_template.query.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/get_starter_template.query.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql index d62fda40237..d62fda40237 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/pipeline.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/pipeline.query.graphql index 021b858d72e..021b858d72e 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/pipeline.query.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/pipeline.query.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/ci/pipeline_editor/graphql/resolvers.js index fa1c70c1994..fa1c70c1994 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/resolvers.js diff --git a/app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/typedefs.graphql index 508ff22c46e..508ff22c46e 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/typedefs.graphql diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/ci/pipeline_editor/index.js index 6d91c339833..6d91c339833 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/ci/pipeline_editor/index.js diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue index ff848a973e3..ff848a973e3 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue index 1972125ed56..1972125ed56 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue index 6e24ac6b8d4..a4ef7827f73 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue @@ -1,18 +1,321 @@ <script> -import { GlForm } from '@gitlab/ui'; +import { + GlButton, + GlDropdown, + GlDropdownItem, + GlFormCheckbox, + GlForm, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlLink, + GlSprintf, +} from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import Vue from 'vue'; +import { __, s__ } from '~/locale'; +import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; +import RefSelector from '~/ref/components/ref_selector.vue'; +import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue'; +import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue'; +import { VARIABLE_TYPE, FILE_TYPE } from '../constants'; export default { components: { + GlButton, + GlDropdown, + GlDropdownItem, GlForm, + GlFormCheckbox, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlLink, + GlSprintf, + RefSelector, + TimezoneDropdown, + IntervalPatternInput, }, - inject: { - fullPath: { + inject: [ + 'fullPath', + 'projectId', + 'defaultBranch', + 'cron', + 'cronTimezone', + 'dailyLimit', + 'settingsLink', + ], + props: { + timezoneData: { + type: Array, + required: true, + }, + refParam: { + type: String, + required: false, default: '', }, }, + data() { + return { + refValue: { + shortName: this.refParam, + // 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 + fullName: this.refParam === this.defaultBranch ? `refs/heads/${this.refParam}` : undefined, + }, + description: '', + scheduleRef: this.defaultBranch, + activated: true, + timezone: this.cronTimezone, + formCiVariables: {}, + // TODO: Add the GraphQL query to help populate the predefined variables + // app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue#131 + predefinedValueOptions: {}, + }; + }, + i18n: { + activated: __('Activated'), + cronTimezone: s__('PipelineSchedules|Cron timezone'), + description: s__('PipelineSchedules|Description'), + shortDescriptionPipeline: s__( + 'PipelineSchedules|Provide a short description for this pipeline', + ), + savePipelineSchedule: s__('PipelineSchedules|Save pipeline schedule'), + cancel: __('Cancel'), + targetBranchTag: __('Select target branch or tag'), + intervalPattern: s__('PipelineSchedules|Interval Pattern'), + variablesDescription: s__( + 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.', + ), + removeVariableLabel: s__('CiVariables|Remove variable'), + variables: s__('Pipeline|Variables'), + }, + typeOptions: { + [VARIABLE_TYPE]: __('Variable'), + [FILE_TYPE]: __('File'), + }, + formElementClasses: 'gl-md-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0', + computed: { + dropdownTranslations() { + return { + dropdownHeader: this.$options.i18n.targetBranchTag, + }; + }, + refFullName() { + return this.refValue.fullName; + }, + variables() { + return this.formCiVariables[this.refFullName]?.variables ?? []; + }, + descriptions() { + return this.formCiVariables[this.refFullName]?.descriptions ?? {}; + }, + typeOptionsListbox() { + return [ + { + text: __('Variable'), + value: VARIABLE_TYPE, + }, + { + text: __('File'), + value: FILE_TYPE, + }, + ]; + }, + getEnabledRefTypes() { + return [REF_TYPE_BRANCHES, REF_TYPE_TAGS]; + }, + }, + created() { + Vue.set(this.formCiVariables, this.refFullName, { + variables: [], + descriptions: {}, + }); + + this.addEmptyVariable(this.refFullName); + }, + methods: { + addEmptyVariable(refValue) { + const { variables } = this.formCiVariables[refValue]; + + const lastVar = variables[variables.length - 1]; + if (lastVar?.key === '' && lastVar?.value === '') { + return; + } + + variables.push({ + uniqueId: uniqueId(`var-${refValue}`), + variable_type: VARIABLE_TYPE, + key: '', + value: '', + }); + }, + setVariableAttribute(key, attribute, value) { + const { variables } = this.formCiVariables[this.refFullName]; + const variable = variables.find((v) => v.key === key); + variable[attribute] = value; + }, + shouldShowValuesDropdown(key) { + return this.predefinedValueOptions[key]?.length > 1; + }, + removeVariable(index) { + this.variables.splice(index, 1); + }, + canRemove(index) { + return index < this.variables.length - 1; + }, + }, }; </script> <template> - <gl-form /> + <div class="col-lg-8"> + <gl-form> + <!--Description--> + <gl-form-group :label="$options.i18n.description" label-for="schedule-description"> + <gl-form-input + id="schedule-description" + v-model="description" + type="text" + :placeholder="$options.i18n.shortDescriptionPipeline" + data-testid="schedule-description" + /> + </gl-form-group> + <!--Interval Pattern--> + <gl-form-group :label="$options.i18n.intervalPattern" label-for="schedule-interval"> + <interval-pattern-input + id="schedule-interval" + :initial-cron-interval="cron" + :daily-limit="dailyLimit" + :send-native-errors="false" + /> + </gl-form-group> + <!--Timezone--> + <gl-form-group :label="$options.i18n.cronTimezone" label-for="schedule-timezone"> + <timezone-dropdown + id="schedule-timezone" + :value="timezone" + :timezone-data="timezoneData" + name="schedule-timezone" + /> + </gl-form-group> + <!--Branch/Tag Selector--> + <gl-form-group :label="$options.i18n.targetBranchTag" label-for="schedule-target-branch-tag"> + <ref-selector + id="schedule-target-branch-tag" + :enabled-ref-types="getEnabledRefTypes" + :project-id="projectId" + :value="scheduleRef" + :use-symbolic-ref-names="true" + :translations="dropdownTranslations" + class="gl-w-full" + /> + </gl-form-group> + <!--Variable List--> + <gl-form-group :label="$options.i18n.variables"> + <div + v-for="(variable, index) in variables" + :key="variable.uniqueId" + class="gl-mb-3 gl-pb-2" + data-testid="ci-variable-row" + data-qa-selector="ci_variable_row_container" + > + <div + class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row" + > + <gl-dropdown + :text="$options.typeOptions[variable.variable_type]" + :class="$options.formElementClasses" + data-testid="pipeline-form-ci-variable-type" + > + <gl-dropdown-item + v-for="type in Object.keys($options.typeOptions)" + :key="type" + @click="setVariableAttribute(variable.key, 'variable_type', type)" + > + {{ $options.typeOptions[type] }} + </gl-dropdown-item> + </gl-dropdown> + <gl-form-input + v-model="variable.key" + :placeholder="s__('CiVariables|Input variable key')" + :class="$options.formElementClasses" + data-testid="pipeline-form-ci-variable-key" + data-qa-selector="ci_variable_key_field" + @change="addEmptyVariable(refFullName)" + /> + <gl-dropdown + v-if="shouldShowValuesDropdown(variable.key)" + :text="variable.value" + :class="$options.formElementClasses" + class="gl-flex-grow-1 gl-mr-0!" + data-testid="pipeline-form-ci-variable-value-dropdown" + > + <gl-dropdown-item + v-for="value in predefinedValueOptions[variable.key]" + :key="value" + data-testid="pipeline-form-ci-variable-value-dropdown-items" + @click="setVariableAttribute(variable.key, 'value', value)" + > + {{ value }} + </gl-dropdown-item> + </gl-dropdown> + <gl-form-textarea + v-else + v-model="variable.value" + :placeholder="s__('CiVariables|Input variable value')" + class="gl-mb-3 gl-h-7!" + :style="$options.textAreaStyle" + :no-resize="false" + data-testid="pipeline-form-ci-variable-value" + data-qa-selector="ci_variable_value_field" + /> + + <template v-if="variables.length > 1"> + <gl-button + v-if="canRemove(index)" + class="gl-md-ml-3 gl-mb-3" + data-testid="remove-ci-variable-row" + variant="danger" + category="secondary" + icon="clear" + :aria-label="$options.i18n.removeVariableLabel" + @click="removeVariable(index)" + /> + <gl-button + v-else + class="gl-md-ml-3 gl-mb-3 gl-display-none gl-md-display-block gl-visibility-hidden" + icon="clear" + :aria-label="$options.i18n.removeVariableLabel" + /> + </template> + </div> + <div v-if="descriptions[variable.key]" class="gl-text-gray-500 gl-mb-3"> + {{ descriptions[variable.key] }} + </div> + </div> + + <template #description + ><gl-sprintf :message="$options.i18n.variablesDescription"> + <template #link="{ content }"> + <gl-link :href="settingsLink">{{ content }}</gl-link> + </template> + </gl-sprintf></template + > + </gl-form-group> + <!--Activated--> + <gl-form-checkbox id="schedule-active" v-model="activated" class="gl-mb-3">{{ + $options.i18n.activated + }}</gl-form-checkbox> + + <gl-button type="submit" variant="confirm" data-testid="schedule-submit-button">{{ + $options.i18n.savePipelineSchedule + }}</gl-button> + <gl-button type="reset" data-testid="schedule-cancel-button">{{ + $options.i18n.cancel + }}</gl-button> + </gl-form> + </div> </template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/constants.js b/app/assets/javascripts/ci/pipeline_schedules/constants.js new file mode 100644 index 00000000000..b4ab1143f60 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/constants.js @@ -0,0 +1,2 @@ +export const VARIABLE_TYPE = 'env_var'; +export const FILE_TYPE = 'file'; diff --git a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js index d83417ab84a..445161f99cb 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js +++ b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js @@ -16,7 +16,16 @@ export default (selector) => { return false; } - const { fullPath } = containerEl.dataset; + const { + fullPath, + cron, + dailyLimit, + timezoneData, + cronTimezone, + projectId, + defaultBranch, + settingsLink, + } = containerEl.dataset; return new Vue({ el: containerEl, @@ -24,9 +33,20 @@ export default (selector) => { apolloProvider, provide: { fullPath, + projectId, + defaultBranch, + dailyLimit: dailyLimit ?? '', + cronTimezone: cronTimezone ?? '', + cron: cron ?? '', + settingsLink, }, render(createElement) { - return createElement(PipelineSchedulesForm); + return createElement(PipelineSchedulesForm, { + props: { + timezoneData: JSON.parse(timezoneData), + refParam: defaultBranch, + }, + }); }, }); }; diff --git a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue b/app/assets/javascripts/ci/reports/codequality_report/components/codequality_issue_body.vue index fb2ef850e4f..5a7ee9c9b28 100644 --- a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue +++ b/app/assets/javascripts/ci/reports/codequality_report/components/codequality_issue_body.vue @@ -5,8 +5,8 @@ */ import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { s__ } from '~/locale'; -import ReportLink from '~/reports/components/report_link.vue'; -import { STATUS_SUCCESS, STATUS_NEUTRAL } from '~/reports/constants'; +import ReportLink from '~/ci/reports/components/report_link.vue'; +import { STATUS_SUCCESS, STATUS_NEUTRAL } from '~/ci/reports/constants'; import { SEVERITY_CLASSES, SEVERITY_ICONS } from '../constants'; export default { diff --git a/app/assets/javascripts/reports/codequality_report/constants.js b/app/assets/javascripts/ci/reports/codequality_report/constants.js index 0c472b24471..5e81245037f 100644 --- a/app/assets/javascripts/reports/codequality_report/constants.js +++ b/app/assets/javascripts/ci/reports/codequality_report/constants.js @@ -16,12 +16,7 @@ export const SEVERITY_ICONS = { unknown: 'severity-unknown', }; -// This is the icons mapping for the code Quality Merge-Request Widget Extension -// once the refactor_mr_widgets_extensions flag is activated the above SEVERITY_ICONS -// need be removed and this variable needs to be rename to SEVERITY_ICONS -// Rollout Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/341759 - -export const SEVERITY_ICONS_EXTENSION = { +export const SEVERITY_ICONS_MR_WIDGET = { info: 'severityInfo', minor: 'severityLow', major: 'severityMedium', @@ -29,3 +24,30 @@ export const SEVERITY_ICONS_EXTENSION = { blocker: 'severityCritical', unknown: 'severityUnknown', }; + +export const SEVERITIES = { + info: { + class: SEVERITY_CLASSES.info, + name: SEVERITY_ICONS.info, + }, + minor: { + class: SEVERITY_CLASSES.minor, + name: SEVERITY_ICONS.minor, + }, + major: { + class: SEVERITY_CLASSES.major, + name: SEVERITY_ICONS.major, + }, + critical: { + class: SEVERITY_CLASSES.critical, + name: SEVERITY_ICONS.critical, + }, + blocker: { + class: SEVERITY_CLASSES.blocker, + name: SEVERITY_ICONS.blocker, + }, + unknown: { + class: SEVERITY_CLASSES.unknown, + name: SEVERITY_ICONS.unknown, + }, +}; diff --git a/app/assets/javascripts/reports/codequality_report/store/actions.js b/app/assets/javascripts/ci/reports/codequality_report/store/actions.js index 04aca11b945..04aca11b945 100644 --- a/app/assets/javascripts/reports/codequality_report/store/actions.js +++ b/app/assets/javascripts/ci/reports/codequality_report/store/actions.js diff --git a/app/assets/javascripts/reports/codequality_report/store/getters.js b/app/assets/javascripts/ci/reports/codequality_report/store/getters.js index 70d11e96a54..70d11e96a54 100644 --- a/app/assets/javascripts/reports/codequality_report/store/getters.js +++ b/app/assets/javascripts/ci/reports/codequality_report/store/getters.js diff --git a/app/assets/javascripts/reports/codequality_report/store/index.js b/app/assets/javascripts/ci/reports/codequality_report/store/index.js index 5bfcd69edec..5bfcd69edec 100644 --- a/app/assets/javascripts/reports/codequality_report/store/index.js +++ b/app/assets/javascripts/ci/reports/codequality_report/store/index.js diff --git a/app/assets/javascripts/reports/codequality_report/store/mutation_types.js b/app/assets/javascripts/ci/reports/codequality_report/store/mutation_types.js index c362c973ae1..c362c973ae1 100644 --- a/app/assets/javascripts/reports/codequality_report/store/mutation_types.js +++ b/app/assets/javascripts/ci/reports/codequality_report/store/mutation_types.js diff --git a/app/assets/javascripts/reports/codequality_report/store/mutations.js b/app/assets/javascripts/ci/reports/codequality_report/store/mutations.js index 249c2f35c0b..249c2f35c0b 100644 --- a/app/assets/javascripts/reports/codequality_report/store/mutations.js +++ b/app/assets/javascripts/ci/reports/codequality_report/store/mutations.js diff --git a/app/assets/javascripts/reports/codequality_report/store/state.js b/app/assets/javascripts/ci/reports/codequality_report/store/state.js index f68dbc2a5fa..f68dbc2a5fa 100644 --- a/app/assets/javascripts/reports/codequality_report/store/state.js +++ b/app/assets/javascripts/ci/reports/codequality_report/store/state.js diff --git a/app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js b/app/assets/javascripts/ci/reports/codequality_report/store/utils/codequality_parser.js index 417297df43c..417297df43c 100644 --- a/app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js +++ b/app/assets/javascripts/ci/reports/codequality_report/store/utils/codequality_parser.js diff --git a/app/assets/javascripts/reports/components/grouped_issues_list.vue b/app/assets/javascripts/ci/reports/components/grouped_issues_list.vue index ca369022938..b21a486e259 100644 --- a/app/assets/javascripts/reports/components/grouped_issues_list.vue +++ b/app/assets/javascripts/ci/reports/components/grouped_issues_list.vue @@ -1,6 +1,6 @@ <script> import { s__ } from '~/locale'; -import ReportItem from '~/reports/components/report_item.vue'; +import ReportItem from '~/ci/reports/components/report_item.vue'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; export default { diff --git a/app/assets/javascripts/reports/components/issue_body.js b/app/assets/javascripts/ci/reports/components/issue_body.js index 4f418216024..daff1be30ff 100644 --- a/app/assets/javascripts/reports/components/issue_body.js +++ b/app/assets/javascripts/ci/reports/components/issue_body.js @@ -1,4 +1,4 @@ -import IssueStatusIcon from '~/reports/components/issue_status_icon.vue'; +import IssueStatusIcon from '~/ci/reports/components/issue_status_icon.vue'; export const components = { CodequalityIssueBody: () => import('../codequality_report/components/codequality_issue_body.vue'), diff --git a/app/assets/javascripts/reports/components/issue_status_icon.vue b/app/assets/javascripts/ci/reports/components/issue_status_icon.vue index bd41b8d23f1..bd41b8d23f1 100644 --- a/app/assets/javascripts/reports/components/issue_status_icon.vue +++ b/app/assets/javascripts/ci/reports/components/issue_status_icon.vue diff --git a/app/assets/javascripts/reports/components/issues_list.vue b/app/assets/javascripts/ci/reports/components/issues_list.vue index 9df0a1953b6..ababd4b5e49 100644 --- a/app/assets/javascripts/reports/components/issues_list.vue +++ b/app/assets/javascripts/ci/reports/components/issues_list.vue @@ -1,6 +1,6 @@ <script> -import ReportItem from '~/reports/components/report_item.vue'; -import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/reports/constants'; +import ReportItem from '~/ci/reports/components/report_item.vue'; +import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/ci/reports/constants'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; const wrapIssueWithState = (status, isNew = false) => (issue) => ({ diff --git a/app/assets/javascripts/reports/components/report_item.vue b/app/assets/javascripts/ci/reports/components/report_item.vue index 918263bfb5c..97d4ac7bf6f 100644 --- a/app/assets/javascripts/reports/components/report_item.vue +++ b/app/assets/javascripts/ci/reports/components/report_item.vue @@ -4,7 +4,7 @@ import { componentNames, iconComponents, iconComponentNames, -} from 'ee_else_ce/reports/components/issue_body'; +} from 'ee_else_ce/ci/reports/components/issue_body'; export default { name: 'ReportItem', diff --git a/app/assets/javascripts/reports/components/report_link.vue b/app/assets/javascripts/ci/reports/components/report_link.vue index 1f68f79e487..1f68f79e487 100644 --- a/app/assets/javascripts/reports/components/report_link.vue +++ b/app/assets/javascripts/ci/reports/components/report_link.vue diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/ci/reports/components/report_section.vue index 468c8916b8d..468c8916b8d 100644 --- a/app/assets/javascripts/reports/components/report_section.vue +++ b/app/assets/javascripts/ci/reports/components/report_section.vue diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/ci/reports/components/summary_row.vue index ee55368c829..ee55368c829 100644 --- a/app/assets/javascripts/reports/components/summary_row.vue +++ b/app/assets/javascripts/ci/reports/components/summary_row.vue diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/ci/reports/constants.js index bad6fa1e7b9..bad6fa1e7b9 100644 --- a/app/assets/javascripts/reports/constants.js +++ b/app/assets/javascripts/ci/reports/constants.js diff --git a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue index 9fa4b521ebc..66d790acb00 100644 --- a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue +++ b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue @@ -1,5 +1,6 @@ <script> -import { GlBadge, GlTabs, GlTab, GlTooltipDirective } from '@gitlab/ui'; +import { GlBadge, GlTabs, GlTab } from '@gitlab/ui'; +import VueRouter from 'vue-router'; import { createAlert, VARIANT_SUCCESS } from '~/flash'; import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; @@ -11,11 +12,28 @@ import RunnerPauseButton from '../components/runner_pause_button.vue'; import RunnerHeader from '../components/runner_header.vue'; import RunnerDetails from '../components/runner_details.vue'; import RunnerJobs from '../components/runner_jobs.vue'; -import { I18N_DETAILS, I18N_FETCH_ERROR } from '../constants'; +import { I18N_DETAILS, I18N_JOBS, I18N_FETCH_ERROR } from '../constants'; import runnerQuery from '../graphql/show/runner.query.graphql'; import { captureException } from '../sentry_utils'; import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage'; +const ROUTE_DETAILS = 'details'; +const ROUTE_JOBS = 'jobs'; + +const routes = [ + { + path: '/', + name: ROUTE_DETAILS, + component: RunnerDetails, + }, + { + path: '/jobs', + name: ROUTE_JOBS, + component: RunnerJobs, + }, + { path: '*', redirect: { name: ROUTE_DETAILS } }, +]; + export default { name: 'AdminRunnerShowApp', components: { @@ -26,12 +44,10 @@ export default { RunnerEditButton, RunnerPauseButton, RunnerHeader, - RunnerDetails, - RunnerJobs, - }, - directives: { - GlTooltip: GlTooltipDirective, }, + router: new VueRouter({ + routes, + }), props: { runnerId: { type: String, @@ -72,11 +88,17 @@ export default { jobCount() { return formatJobCount(this.runner?.jobCount); }, + tabIndex() { + return routes.findIndex(({ name }) => name === this.$route.name); + }, }, errorCaptured(error) { this.reportToSentry(error); }, methods: { + goTo(name) { + this.$router.push({ name }); + }, reportToSentry(error) { captureException({ error, component: this.$options.name }); }, @@ -85,7 +107,10 @@ export default { redirectTo(this.runnersPath); }, }, + ROUTE_DETAILS, + ROUTE_JOBS, I18N_DETAILS, + I18N_JOBS, }; </script> <template> @@ -98,15 +123,13 @@ export default { </template> </runner-header> - <gl-tabs> - <gl-tab> + <gl-tabs :value="tabIndex"> + <gl-tab @click="goTo($options.ROUTE_DETAILS)"> <template #title>{{ $options.I18N_DETAILS }}</template> - - <runner-details v-if="runner" :runner="runner" /> </gl-tab> - <gl-tab> + <gl-tab @click="goTo($options.ROUTE_JOBS)"> <template #title> - {{ s__('Runners|Jobs') }} + {{ $options.I18N_JOBS }} <gl-badge v-if="jobCount" data-testid="job-count-badge" @@ -116,9 +139,9 @@ export default { {{ jobCount }} </gl-badge> </template> - - <runner-jobs v-if="runner" :runner="runner" /> </gl-tab> + + <router-view v-if="runner" :runner="runner" /> </gl-tabs> </div> </template> diff --git a/app/assets/javascripts/ci/runner/admin_runner_show/index.js b/app/assets/javascripts/ci/runner/admin_runner_show/index.js index ea455416648..cbd25819303 100644 --- a/app/assets/javascripts/ci/runner/admin_runner_show/index.js +++ b/app/assets/javascripts/ci/runner/admin_runner_show/index.js @@ -1,10 +1,12 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; import createDefaultClient from '~/lib/graphql'; import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage'; import AdminRunnerShowApp from './admin_runner_show_app.vue'; Vue.use(VueApollo); +Vue.use(VueRouter); export const initAdminRunnerShow = (selector = '#js-admin-runner-show') => { showAlertFromLocalStorage(); diff --git a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue index 2915e460085..3bd20dff9cc 100644 --- a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue @@ -23,6 +23,7 @@ import RunnerStats from '../components/stat/runner_stats.vue'; import RunnerPagination from '../components/runner_pagination.vue'; import RunnerTypeTabs from '../components/runner_type_tabs.vue'; import RunnerActionsCell from '../components/cells/runner_actions_cell.vue'; +import RunnerJobStatusBadge from '../components/runner_job_status_badge.vue'; import { pausedTokenConfig } from '../components/search_tokens/paused_token_config'; import { statusTokenConfig } from '../components/search_tokens/status_token_config'; @@ -48,6 +49,7 @@ export default { RunnerPagination, RunnerTypeTabs, RunnerActionsCell, + RunnerJobStatusBadge, }, mixins: [glFeatureFlagMixin()], inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'], @@ -69,6 +71,9 @@ export default { apollo: { runners: { query: allRunnersQuery, + context: { + isSingleRequest: true, + }, fetchPolicy: fetchPolicies.NETWORK_ONLY, variables() { return this.variables; @@ -134,6 +139,12 @@ export default { this.reportToSentry(error); }, methods: { + jobsUrl(runner) { + const url = new URL(runner.adminUrl); + url.hash = '#/jobs'; + + return url.href; + }, onToggledPaused() { // When a runner becomes Paused, the tab count can // become stale, refetch outdated counts. @@ -208,6 +219,12 @@ export default { <runner-name :runner="runner" /> </gl-link> </template> + <template #runner-job-status-badge="{ runner }"> + <runner-job-status-badge + :href="jobsUrl(runner)" + :job-status="runner.jobExecutionStatus" + /> + </template> <template #runner-actions-cell="{ runner }"> <runner-actions-cell :runner="runner" diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue index 67b9b0a266f..cfbe37f5ba2 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue @@ -7,8 +7,6 @@ import RunnerPausedBadge from '../runner_paused_badge.vue'; export default { components: { RunnerStatusBadge, - RunnerUpgradeStatusBadge: () => - import('ee_component/ci/runner/components/runner_upgrade_status_badge.vue'), RunnerPausedBadge, }, directives: { @@ -34,10 +32,6 @@ export default { :runner="runner" class="gl-display-inline-block gl-max-w-full gl-text-truncate" /> - <runner-upgrade-status-badge - :runner="runner" - class="gl-display-inline-block gl-max-w-full gl-text-truncate" - /> <runner-paused-badge v-if="paused" class="gl-display-inline-block gl-max-w-full gl-text-truncate" diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_stacked_summary_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue index 1e44d5fccc2..4a72023b6a0 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_stacked_summary_cell.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue @@ -6,9 +6,11 @@ import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import RunnerName from '../runner_name.vue'; import RunnerTags from '../runner_tags.vue'; import RunnerTypeBadge from '../runner_type_badge.vue'; +import RunnerJobStatusBadge from '../runner_job_status_badge.vue'; import { formatJobCount } from '../../utils'; import { + I18N_NO_DESCRIPTION, I18N_LOCKED_RUNNER_DESCRIPTION, I18N_VERSION_LABEL, I18N_LAST_CONTACT_LABEL, @@ -25,6 +27,7 @@ export default { RunnerName, RunnerTags, RunnerTypeBadge, + RunnerJobStatusBadge, RunnerUpgradeStatusIcon: () => import('ee_component/ci/runner/components/runner_upgrade_status_icon.vue'), TooltipOnTruncate, @@ -44,6 +47,7 @@ export default { }, }, i18n: { + I18N_NO_DESCRIPTION, I18N_LOCKED_RUNNER_DESCRIPTION, I18N_VERSION_LABEL, I18N_LAST_CONTACT_LABEL, @@ -75,12 +79,21 @@ export default { </gl-sprintf> </div> <div class="gl-text-secondary gl-mx-2" aria-hidden="true">·</div> - <tooltip-on-truncate class="gl-text-truncate gl-display-block" :title="runner.description"> + <tooltip-on-truncate + v-if="runner.description" + class="gl-text-truncate gl-display-block" + :title="runner.description" + > {{ runner.description }} </tooltip-on-truncate> + <span v-else class="gl-text-secondary">{{ $options.i18n.I18N_NO_DESCRIPTION }}</span> </div> <div> + <slot :runner="runner" name="runner-job-status-badge"> + <runner-job-status-badge :job-status="runner.jobExecutionStatus" /> + </slot> + <runner-summary-field icon="clock"> <gl-sprintf :message="$options.i18n.I18N_LAST_CONTACT_LABEL"> <template #timeAgo> diff --git a/app/assets/javascripts/ci/runner/components/runner_detail.vue b/app/assets/javascripts/ci/runner/components/runner_detail.vue index c260670b517..9e8055a8432 100644 --- a/app/assets/javascripts/ci/runner/components/runner_detail.vue +++ b/app/assets/javascripts/ci/runner/components/runner_detail.vue @@ -49,7 +49,7 @@ export default { <template v-if="value || $scopedSlots.value"> <slot name="value">{{ value }}</slot> </template> - <span v-else class="gl-text-gray-500">{{ emptyValue }}</span> + <span v-else class="gl-text-secondary">{{ emptyValue }}</span> </dd> </div> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_groups.vue b/app/assets/javascripts/ci/runner/components/runner_groups.vue index c3b35bd52a9..8501d165157 100644 --- a/app/assets/javascripts/ci/runner/components/runner_groups.vue +++ b/app/assets/javascripts/ci/runner/components/runner_groups.vue @@ -32,6 +32,6 @@ export default { :avatar-url="group.avatarUrl" /> </template> - <span v-else class="gl-text-gray-500">{{ __('None') }}</span> + <span v-else class="gl-text-secondary">{{ __('None') }}</span> </div> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue b/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue new file mode 100644 index 00000000000..1e52acecfb8 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue @@ -0,0 +1,55 @@ +<script> +import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { + I18N_JOB_STATUS_RUNNING, + I18N_JOB_STATUS_IDLE, + JOB_STATUS_RUNNING, + JOB_STATUS_IDLE, +} from '../constants'; + +export default { + components: { + GlBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + jobStatus: { + required: false, + default: null, + type: String, + }, + }, + computed: { + badge() { + switch (this.jobStatus) { + case JOB_STATUS_RUNNING: + return { + classes: 'gl-text-blue-600! gl-border gl-border-blue-600!', + label: I18N_JOB_STATUS_RUNNING, + }; + case JOB_STATUS_IDLE: + return { + classes: 'gl-text-gray-700! gl-border gl-border-gray-500!', + label: I18N_JOB_STATUS_IDLE, + }; + default: + return null; + } + }, + }, +}; +</script> +<template> + <gl-badge + v-if="badge" + v-bind="$attrs" + size="sm" + class="gl-mr-3 gl-bg-transparent!" + variant="muted" + :class="badge.classes" + > + {{ badge.label }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_list.vue b/app/assets/javascripts/ci/runner/components/runner_list.vue index e895537dcdc..b2aad0aac4f 100644 --- a/app/assets/javascripts/ci/runner/components/runner_list.vue +++ b/app/assets/javascripts/ci/runner/components/runner_list.vue @@ -7,7 +7,7 @@ import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.grap import { formatJobCount, tableField } from '../utils'; import RunnerBulkDelete from './runner_bulk_delete.vue'; import RunnerBulkDeleteCheckbox from './runner_bulk_delete_checkbox.vue'; -import RunnerStackedSummaryCell from './cells/runner_stacked_summary_cell.vue'; +import RunnerSummaryCell from './cells/runner_summary_cell.vue'; import RunnerStatusPopover from './runner_status_popover.vue'; import RunnerStatusCell from './cells/runner_status_cell.vue'; import RunnerOwnerCell from './cells/runner_owner_cell.vue'; @@ -28,7 +28,7 @@ export default { RunnerBulkDelete, RunnerBulkDeleteCheckbox, RunnerStatusPopover, - RunnerStackedSummaryCell, + RunnerSummaryCell, RunnerStatusCell, RunnerOwnerCell, }, @@ -154,11 +154,14 @@ export default { </template> <template #cell(summary)="{ item, index }"> - <runner-stacked-summary-cell :runner="item"> + <runner-summary-cell :runner="item"> <template #runner-name="{ runner }"> <slot name="runner-name" :runner="runner" :index="index"></slot> </template> - </runner-stacked-summary-cell> + <template #runner-job-status-badge="{ runner }"> + <slot name="runner-job-status-badge" :runner="runner" :index="index"></slot> + </template> + </runner-summary-cell> </template> <template #head(owner)="{ label }"> diff --git a/app/assets/javascripts/ci/runner/components/runner_projects.vue b/app/assets/javascripts/ci/runner/components/runner_projects.vue index 84008e8eee8..4a6e90b44a9 100644 --- a/app/assets/javascripts/ci/runner/components/runner_projects.vue +++ b/app/assets/javascripts/ci/runner/components/runner_projects.vue @@ -133,7 +133,7 @@ export default { :is-owner="isOwner(project.id)" /> </template> - <div v-else class="gl-py-5 gl-text-gray-500">{{ $options.I18N_NO_PROJECTS_FOUND }}</div> + <div v-else class="gl-py-5 gl-text-secondary">{{ $options.I18N_NO_PROJECTS_FOUND }}</div> <runner-pagination :disabled="loading" diff --git a/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue b/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue index 584236168ac..70226074993 100644 --- a/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue +++ b/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue @@ -59,21 +59,20 @@ export default { return [ { title: I18N_ALL_TYPES, - runnerType: null, }, ...tabs, ]; }, }, methods: { - onTabSelected({ runnerType }) { + onTabSelected(runnerType) { this.$emit('input', { ...this.value, runnerType, pagination: { page: 1 }, }); }, - isTabActive({ runnerType }) { + isTabActive(runnerType = null) { return runnerType === this.value.runnerType; }, tabBadgeCountVariables(runnerType) { @@ -102,8 +101,8 @@ export default { <gl-tab v-for="tab in tabs" :key="`${tab.runnerType}`" - :active="isTabActive(tab)" - @click="onTabSelected(tab)" + :active="isTabActive(tab.runnerType)" + @click="onTabSelected(tab.runnerType)" > <template #title> {{ tab.title }} diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js index 97ee8ec3eef..71a145dd4a3 100644 --- a/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js +++ b/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js @@ -1,5 +1,5 @@ import { __ } from '~/locale'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import { PARAM_KEY_PAUSED, I18N_PAUSED } from '../../constants'; @@ -24,5 +24,5 @@ export const pausedTokenConfig = { // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438 title: title.replace(/\s/g, '\u00a0'), })), - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }; diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js index 117a630719e..4bc32909777 100644 --- a/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js +++ b/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js @@ -1,5 +1,5 @@ import { - OPERATOR_IS_ONLY, + OPERATORS_IS, TOKEN_TITLE_STATUS, } from '~/vue_shared/components/filtered_search_bar/constants'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -38,5 +38,5 @@ export const statusTokenConfig = { // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438 title: title.replace(/\s/g, '\u00a0'), })), - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }; diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js index fdeba714385..369b214f952 100644 --- a/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js +++ b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js @@ -1,5 +1,5 @@ import { s__ } from '~/locale'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; import { PARAM_KEY_TAG } from '../../constants'; import TagToken from './tag_token.vue'; @@ -8,5 +8,5 @@ export const tagTokenConfig = { title: s__('Runners|Tags'), type: PARAM_KEY_TAG, token: TagToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }; diff --git a/app/assets/javascripts/ci/runner/components/stat/runner_count.vue b/app/assets/javascripts/ci/runner/components/stat/runner_count.vue index 4ad9259f59d..c33c42f3afe 100644 --- a/app/assets/javascripts/ci/runner/components/stat/runner_count.vue +++ b/app/assets/javascripts/ci/runner/components/stat/runner_count.vue @@ -16,13 +16,13 @@ import { INSTANCE_TYPE, GROUP_TYPE } from '../../constants'; * <strong/> tag. * * ```vue - * <runner-count-stat + * <runner-count * #default="{ count }" * :scope="INSTANCE_TYPE" * :variables="{ status: 'ONLINE' }" * > * <strong>{{ count }}</strong> - * </runner-count-stat> + * </runner-count> * ``` * * Use `:skip="true"` to prevent data from being fetched and diff --git a/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue index 3965e5551f1..2e50dc13d2d 100644 --- a/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue +++ b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue @@ -1,5 +1,4 @@ <script> -import RunnerSingleStat from '~/ci/runner/components/stat/runner_single_stat.vue'; import { I18N_STATUS_ONLINE, I18N_STATUS_OFFLINE, @@ -8,9 +7,19 @@ import { STATUS_OFFLINE, STATUS_STALE, } from '../../constants'; +import RunnerSingleStat from './runner_single_stat.vue'; +import RunnerCount from './runner_count.vue'; + +/** + * Shows general stats about the runners. + * + * First it checks if there are any runners in this context, and if so, + * shows more details for different status. + */ export default { components: { + RunnerCount, RunnerSingleStat, RunnerUpgradeStatusStats: () => import('ee_component/ci/runner/components/stat/runner_upgrade_status_stats.vue'), @@ -71,19 +80,21 @@ export default { }; </script> <template> - <div class="gl-display-flex gl-flex-wrap gl-py-6"> - <runner-single-stat - v-for="stat in stats" - :key="stat.key" - :scope="scope" - v-bind="stat.props" - class="gl-px-5" - /> + <runner-count #default="{ count }" :scope="scope" :variables="variables"> + <div v-if="count" class="gl-display-flex gl-flex-wrap gl-py-6"> + <runner-single-stat + v-for="stat in stats" + :key="stat.key" + :scope="scope" + v-bind="stat.props" + class="gl-px-5" + /> - <runner-upgrade-status-stats - class="gl-display-contents" - :scope="scope" - :variables="variables" - /> - </div> + <runner-upgrade-status-stats + class="gl-display-contents" + :scope="scope" + :variables="variables" + /> + </div> + </runner-count> </template> diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js index dfc5f0c4152..31900a1fe89 100644 --- a/app/assets/javascripts/ci/runner/constants.js +++ b/app/assets/javascripts/ci/runner/constants.js @@ -32,6 +32,10 @@ export const I18N_STATUS_NEVER_CONTACTED = s__('Runners|Never contacted'); export const I18N_STATUS_OFFLINE = s__('Runners|Offline'); export const I18N_STATUS_STALE = s__('Runners|Stale'); +// Executor Status +export const I18N_JOB_STATUS_RUNNING = s__('Runners|Running'); +export const I18N_JOB_STATUS_IDLE = s__('Runners|Idle'); + // Status help popover export const I18N_STATUS_POPOVER_TITLE = s__('Runners|Runner statuses'); @@ -82,6 +86,7 @@ export const I18N_DELETE_RUNNER = s__('Runners|Delete runner'); export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted'); // List +export const I18N_NO_DESCRIPTION = s__('Runners|No description'); export const I18N_LOCKED_RUNNER_DESCRIPTION = s__( 'Runners|Runner is locked and available for currently assigned projects only. Only administrators can change the assigned projects.', ); @@ -94,6 +99,7 @@ export const I18N_ADMIN = s__('Runners|Administrator'); // Runner details export const I18N_DETAILS = s__('Runners|Details'); +export const I18N_JOBS = s__('Runners|Jobs'); export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})'); export const I18N_FILTER_PROJECTS = s__('Runners|Filter projects'); export const I18N_CLEAR_FILTER_PROJECTS = __('Clear'); @@ -134,6 +140,11 @@ export const STATUS_NEVER_CONTACTED = 'NEVER_CONTACTED'; export const STATUS_OFFLINE = 'OFFLINE'; export const STATUS_STALE = 'STALE'; +// CiRunnerJobExecutionStatus + +export const JOB_STATUS_RUNNING = 'RUNNING'; +export const JOB_STATUS_IDLE = 'IDLE'; + // CiRunnerAccessLevel export const ACCESS_LEVEL_NOT_PROTECTED = 'NOT_PROTECTED'; diff --git a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql index 0dff011daaa..6f72509f599 100644 --- a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql +++ b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql @@ -12,6 +12,7 @@ fragment ListItemShared on CiRunner { createdAt contactedAt status(legacyMode: null) + jobExecutionStatus userPermissions { updateRunner deleteRunner diff --git a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue index 91c22923075..57ceaa24b6e 100644 --- a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue @@ -82,6 +82,9 @@ export default { apollo: { runners: { query: groupRunnersQuery, + context: { + isSingleRequest: true, + }, fetchPolicy: fetchPolicies.NETWORK_ONLY, variables() { return this.variables; diff --git a/app/assets/javascripts/ci/runner/runner_search_utils.js b/app/assets/javascripts/ci/runner/runner_search_utils.js index adc832b0600..3dc99baa329 100644 --- a/app/assets/javascripts/ci/runner/runner_search_utils.js +++ b/app/assets/javascripts/ci/runner/runner_search_utils.js @@ -176,6 +176,7 @@ export const fromSearchToUrl = ( [PARAM_KEY_RUNNER_TYPE]: [], [PARAM_KEY_MEMBERSHIP]: [], [PARAM_KEY_TAG]: [], + [PARAM_KEY_PAUSED]: [], // Current filters ...filterToQueryObject(processFilters(filters), { filteredSearchTermKey: PARAM_KEY_SEARCH, diff --git a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue index 9d8cb40b60a..661389f4059 100644 --- a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue +++ b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue @@ -13,7 +13,7 @@ import { } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import Api, { DEFAULT_PER_PAGE } from '~/api'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_PAYLOAD_TOO_LARGE } from '~/lib/utils/http_status'; import { __, s__, sprintf } from '~/locale'; import Tracking from '~/tracking'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -145,7 +145,7 @@ export default { let message = ''; if (error?.response?.data?.message?.name) { message = this.$options.i18n.uploadErrorMessages.duplicate; - } else if (error.response.status === httpStatusCodes.PAYLOAD_TOO_LARGE) { + } else if (error.response.status === HTTP_STATUS_PAYLOAD_TOO_LARGE) { message = sprintf(this.$options.i18n.uploadErrorMessages.tooLarge, { limit: this.fileSizeLimit, }); diff --git a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue index c8f5ac1736d..4466a6a8081 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue @@ -46,6 +46,7 @@ export default { :id="graphqlId" :are-scoped-variables-available="areScopedVariablesAvailable" component-name="GroupVariables" + entity="group" :full-path="groupPath" :mutation-data="$options.mutationData" :query-data="$options.queryData" diff --git a/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue index 2c4818e20c1..6326940148a 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue @@ -48,6 +48,7 @@ export default { :id="graphqlId" :are-scoped-variables-available="true" component-name="ProjectVariables" + entity="project" :full-path="projectFullPath" :mutation-data="$options.mutationData" :query-data="$options.queryData" diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue index 94f8cb9e906..00177539cdc 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue @@ -29,6 +29,7 @@ import { ENVIRONMENT_SCOPE_LINK_TITLE, EVENT_LABEL, EVENT_ACTION, + EXPANDED_VARIABLES_NOTE, EDIT_VARIABLE_ACTION, VARIABLE_ACTIONS, variableOptions, @@ -46,6 +47,7 @@ export default { awsTipMessage: AWS_TIP_MESSAGE, containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE, environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE, + expandedVariablesNote: EXPANDED_VARIABLES_NOTE, components: { CiEnvironmentsDropdown, GlAlert, @@ -127,7 +129,7 @@ export default { }, containsVariableReference() { const regex = /\$/; - return regex.test(this.variable.value); + return regex.test(this.variable.value) && this.isExpanded; }, displayMaskedError() { return !this.canMask && this.variable.masked; @@ -135,6 +137,9 @@ export default { isEditing() { return this.mode === EDIT_VARIABLE_ACTION; }, + isExpanded() { + return !this.variable.raw; + }, isTipVisible() { return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key); }, @@ -208,6 +213,9 @@ export default { hideModal() { this.$refs.modal.hide(); }, + onShow() { + this.setVariableProtectedByDefault(); + }, resetModalHandler() { this.resetVariableData(); this.resetValidationErrorEvents(); @@ -220,6 +228,9 @@ export default { setEnvironmentScope(scope) { this.variable = { ...this.variable, environmentScope: scope }; }, + setVariableRaw(expanded) { + this.variable = { ...this.variable, raw: !expanded }; + }, setVariableProtected() { this.variable = { ...this.variable, protected: true }; }, @@ -275,7 +286,7 @@ export default { static lazy @hidden="resetModalHandler" - @shown="setVariableProtectedByDefault" + @shown="onShow" > <form> <gl-form-combobox @@ -304,6 +315,13 @@ export default { class="gl-font-monospace!" spellcheck="false" /> + <p + v-if="variable.raw" + class="gl-mt-2 gl-mb-0 text-secondary" + data-testid="raw-variable-tip" + > + {{ __('Variable value will be evaluated as raw string.') }} + </p> </gl-form-group> <div class="gl-display-flex"> @@ -361,7 +379,6 @@ export default { {{ __('Export variable to pipelines running on protected branches and tags only.') }} </p> </gl-form-checkbox> - <gl-form-checkbox ref="masked-ci-variable" v-model="variable.masked" @@ -371,7 +388,7 @@ export default { <gl-link target="_blank" :href="maskedEnvironmentVariablesLink"> <gl-icon name="question" :size="12" /> </gl-link> - <p class="gl-mt-2 gl-mb-0 text-secondary"> + <p class="gl-mt-2 text-secondary"> {{ __('Variable will be masked in job logs.') }} <span :class="{ @@ -385,6 +402,24 @@ export default { }}</gl-link> </p> </gl-form-checkbox> + <gl-form-checkbox + ref="expanded-ci-variable" + :checked="isExpanded" + data-testid="ci-variable-expanded-checkbox" + @change="setVariableRaw" + > + {{ __('Expand variable reference') }} + <gl-link target="_blank" :href="containsVariableReferenceLink"> + <gl-icon name="question" :size="12" /> + </gl-link> + <p class="gl-mt-2 gl-mb-0 gl-text-secondary"> + <gl-sprintf :message="$options.expandedVariablesNote"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + </gl-form-checkbox> </gl-form-group> </form> <gl-collapse :visible="isTipVisible"> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue index 94fd6c3892c..3c6114b38ce 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue @@ -14,6 +14,11 @@ export default { required: false, default: false, }, + entity: { + type: String, + required: false, + default: '', + }, environments: { type: Array, required: false, @@ -27,7 +32,11 @@ export default { isLoading: { type: Boolean, required: false, - default: false, + }, + maxVariableLimit: { + type: Number, + required: false, + default: 0, }, variables: { type: Array, @@ -75,7 +84,9 @@ export default { <div class="row"> <div class="col-lg-12"> <ci-variable-table + :entity="entity" :is-loading="isLoading" + :max-variable-limit="maxVariableLimit" :variables="variables" @set-selected-variable="setSelectedVariable" /> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_shared.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_shared.vue index 7ee250cea98..6e39bda0b07 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_shared.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_shared.vue @@ -26,6 +26,11 @@ export default { required: true, type: String, }, + entity: { + required: false, + type: String, + default: '', + }, fullPath: { required: false, type: String, @@ -90,6 +95,7 @@ export default { isInitialLoading: true, isLoadingMoreItems: false, loadingCounter: 0, + maxVariableLimit: 0, pageInfo: {}, }; }, @@ -107,6 +113,8 @@ export default { return this.queryData.ciVariables.lookup(data)?.nodes || []; }, result({ data }) { + this.maxVariableLimit = this.queryData.ciVariables.lookup(data)?.limit || 0; + this.pageInfo = this.queryData.ciVariables.lookup(data)?.pageInfo || this.pageInfo; this.hasNextPage = this.pageInfo?.hasNextPage || false; @@ -221,9 +229,11 @@ export default { <template> <ci-variable-settings :are-scoped-variables-available="areScopedVariablesAvailable" + :entity="entity" :hide-environment-scope="hideEnvironmentScope" :is-loading="isLoading" :variables="ciVariables" + :max-variable-limit="maxVariableLimit" :environments="environments" @add-variable="addVariable" @delete-variable="deleteVariable" diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue index 3cdcb68e919..345a8def49d 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue @@ -1,8 +1,21 @@ <script> -import { GlButton, GlLoadingIcon, GlModalDirective, GlTable, GlTooltipDirective } from '@gitlab/ui'; -import { s__, __ } from '~/locale'; +import { + GlAlert, + GlButton, + GlLoadingIcon, + GlModalDirective, + GlTable, + GlTooltipDirective, +} from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { ADD_CI_VARIABLE_MODAL_ID, variableText } from '../constants'; +import { + ADD_CI_VARIABLE_MODAL_ID, + DEFAULT_EXCEEDS_VARIABLE_LIMIT_TEXT, + EXCEEDS_VARIABLE_LIMIT_TEXT, + MAXIMUM_VARIABLE_LIMIT_REACHED, + variableText, +} from '../constants'; import { convertEnvironmentScope } from '../utils'; export default { @@ -41,6 +54,7 @@ export default { }, ], components: { + GlAlert, GlButton, GlLoadingIcon, GlTable, @@ -51,10 +65,19 @@ export default { }, mixins: [glFeatureFlagsMixin()], props: { + entity: { + type: String, + required: false, + default: '', + }, isLoading: { type: Boolean, required: true, }, + maxVariableLimit: { + type: Number, + required: true, + }, variables: { type: Array, required: true, @@ -66,6 +89,23 @@ export default { }; }, computed: { + exceedsVariableLimit() { + return this.maxVariableLimit > 0 && this.variables.length >= this.maxVariableLimit; + }, + exceedsVariableLimitText() { + if (this.exceedsVariableLimit && this.entity) { + return sprintf(EXCEEDS_VARIABLE_LIMIT_TEXT, { + entity: this.entity, + currentVariableCount: this.variables.length, + maxVariableLimit: this.maxVariableLimit, + }); + } + + return DEFAULT_EXCEEDS_VARIABLE_LIMIT_TEXT; + }, + showAlert() { + return !this.isLoading && this.exceedsVariableLimit; + }, valuesButtonText() { return this.areValuesHidden ? __('Reveal values') : __('Hide values'); }, @@ -104,17 +144,29 @@ export default { if (item.masked) { options.push(s__('CiVariables|Masked')); } + if (!item.raw) { + options.push(s__('CiVariables|Expanded')); + } return options.join(', '); }, }, + maximumVariableLimitReached: MAXIMUM_VARIABLE_LIMIT_REACHED, }; </script> <template> <div class="ci-variable-table" data-testid="ci-variable-table"> <gl-loading-icon v-if="isLoading" /> + <gl-alert + v-if="showAlert" + :dismissible="false" + :title="$options.maximumVariableLimitReached" + variant="info" + > + {{ exceedsVariableLimitText }} + </gl-alert> <gl-table - v-else + v-if="!isLoading" :fields="fields" :items="variablesWithOptions" tbody-tr-class="js-ci-variable-row" @@ -178,7 +230,7 @@ export default { </div> </template> <template #cell(options)="{ item }"> - <span>{{ item.options }}</span> + <span data-testid="ci-variable-table-row-options">{{ item.options }}</span> </template> <template #cell(environmentScope)="{ item }"> <div @@ -215,6 +267,14 @@ export default { </p> </template> </gl-table> + <gl-alert + v-if="showAlert" + :dismissible="false" + :title="$options.maximumVariableLimitReached" + variant="info" + > + {{ exceedsVariableLimitText }} + </gl-alert> <div class="ci-variable-actions gl-display-flex gl-mt-5"> <gl-button v-gl-modal-directive="$options.modalId" @@ -223,6 +283,7 @@ export default { variant="confirm" category="primary" :aria-label="__('Add')" + :disabled="exceedsVariableLimit" @click="setSelectedVariable()" >{{ __('Add variable') }}</gl-button > diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js index ccad08ef8b6..828d0724d93 100644 --- a/app/assets/javascripts/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci_variable_list/constants.js @@ -1,4 +1,4 @@ -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; export const ADD_CI_VARIABLE_MODAL_ID = 'add-ci-variable'; @@ -43,6 +43,7 @@ export const defaultVariableState = { key: '', masked: false, protected: false, + raw: false, value: '', variableType: variableTypes.envType, }; @@ -69,10 +70,19 @@ export const AWS_SECRET_ACCESS_KEY = 'AWS_SECRET_ACCESS_KEY'; export const AWS_TOKEN_CONSTANTS = [AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_SECRET_ACCESS_KEY]; export const CONTAINS_VARIABLE_REFERENCE_MESSAGE = __( - 'Values that contain the %{codeStart}$%{codeEnd} character can be considered a variable reference and expanded. %{docsLinkStart}Learn more.%{docsLinkEnd}', + 'Unselect "Expand variable reference" if you want to use the variable value as a raw string.', ); export const ENVIRONMENT_SCOPE_LINK_TITLE = __('Learn more'); +export const EXCEEDS_VARIABLE_LIMIT_TEXT = s__( + 'CiVariables|This %{entity} has %{currentVariableCount} defined CI/CD variables. The maximum number of variables per %{entity} is %{maxVariableLimit}. To add new variables, you must reduce the number of defined variables.', +); +export const DEFAULT_EXCEEDS_VARIABLE_LIMIT_TEXT = s__( + 'CiVariables|You have reached the maximum number of variables available. To add new variables, you must reduce the number of defined variables.', +); +export const MAXIMUM_VARIABLE_LIMIT_REACHED = s__( + 'CiVariables|Maximum number of variables reached.', +); export const ADD_VARIABLE_ACTION = 'ADD_VARIABLE'; export const EDIT_VARIABLE_ACTION = 'EDIT_VARIABLE'; @@ -85,6 +95,10 @@ export const ADD_MUTATION_ACTION = 'add'; export const UPDATE_MUTATION_ACTION = 'update'; export const DELETE_MUTATION_ACTION = 'delete'; +export const EXPANDED_VARIABLES_NOTE = __( + '%{codeStart}$%{codeEnd} will be treated as the start of a reference to another variable.', +); + export const environmentFetchErrorText = __( 'There was an error fetching the environments information.', ); diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql index c44ee2ecc1d..24388637672 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql @@ -16,6 +16,7 @@ mutation addGroupVariable($variable: CiVariable!, $endpoint: String!, $fullPath: environmentScope masked protected + raw } } } diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql index 53e9b411dd2..f7c8e209ccd 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql @@ -16,6 +16,7 @@ mutation deleteGroupVariable($variable: CiVariable!, $endpoint: String!, $fullPa environmentScope masked protected + raw } } } diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql index 2dddca14bd8..757e61a5cd3 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql @@ -16,6 +16,7 @@ mutation updateGroupVariable($variable: CiVariable!, $endpoint: String!, $fullPa environmentScope masked protected + raw } } } diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql index 39504770e33..fa315084d86 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql @@ -16,6 +16,7 @@ mutation addProjectVariable($variable: CiVariable!, $endpoint: String!, $fullPat environmentScope masked protected + raw } } } diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql index f55c255e332..c3358cc35b9 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql @@ -21,6 +21,7 @@ mutation deleteProjectVariable( environmentScope masked protected + raw } } } diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql index fc589e8a939..fde92cef4cb 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql @@ -21,6 +21,7 @@ mutation updateProjectVariable( environmentScope masked protected + raw } } } diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql b/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql index b5555fe4401..900154cd24d 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql +++ b/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql @@ -5,6 +5,7 @@ query getGroupVariables($after: String, $first: Int = 100, $fullPath: ID!) { group(fullPath: $fullPath) { id ciVariables(after: $after, first: $first) { + limit pageInfo { ...PageInfo } @@ -14,6 +15,7 @@ query getGroupVariables($after: String, $first: Int = 100, $fullPath: ID!) { environmentScope masked protected + raw } } } diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql b/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql index 08b5bf7af16..ee75eba7547 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql +++ b/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql @@ -5,6 +5,7 @@ query getProjectVariables($after: String, $first: Int = 100, $fullPath: ID!) { project(fullPath: $fullPath) { id ciVariables(after: $after, first: $first) { + limit pageInfo { ...PageInfo } @@ -13,6 +14,7 @@ query getProjectVariables($after: String, $first: Int = 100, $fullPath: ID!) { environmentScope masked protected + raw } } } diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql b/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql index 2667d6606fe..9b255c3c182 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql +++ b/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql @@ -11,6 +11,7 @@ query getVariables($after: String, $first: Int = 100) { ... on CiInstanceVariable { masked protected + raw } } } diff --git a/app/assets/javascripts/clusters_list/clusters_util.js b/app/assets/javascripts/clusters_list/clusters_util.js index ee36a295513..25a8426500e 100644 --- a/app/assets/javascripts/clusters_list/clusters_util.js +++ b/app/assets/javascripts/clusters_list/clusters_util.js @@ -1,10 +1,14 @@ -import { ACTIVE_CONNECTION_TIME } from './constants'; +import { ACTIVE_CONNECTION_TIME, NAME_MAX_LENGTH } from './constants'; + +function getTruncatedName(name) { + return name.substring(0, NAME_MAX_LENGTH); +} export function generateAgentRegistrationCommand({ name, token, version, address }) { return `helm repo add gitlab https://charts.gitlab.io helm repo update helm upgrade --install ${name} gitlab/gitlab-agent \\ - --namespace gitlab-agent \\ + --namespace gitlab-agent-${getTruncatedName(name)} \\ --create-namespace \\ --set image.tag=v${version} \\ --set config.token=${token} \\ diff --git a/app/assets/javascripts/clusters_list/components/agent_token.vue b/app/assets/javascripts/clusters_list/components/agent_token.vue index 4dd6d84566c..93c37226a09 100644 --- a/app/assets/javascripts/clusters_list/components/agent_token.vue +++ b/app/assets/javascripts/clusters_list/components/agent_token.vue @@ -1,22 +1,24 @@ <script> -import { GlAlert, GlFormInputGroup, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlFormInputGroup, GlLink, GlSprintf, GlIcon } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; import CodeBlock from '~/vue_shared/components/code_block.vue'; import { generateAgentRegistrationCommand } from '../clusters_util'; -import { I18N_AGENT_TOKEN } from '../constants'; +import { I18N_AGENT_TOKEN, HELM_VERSION_POLICY_URL } from '../constants'; export default { i18n: I18N_AGENT_TOKEN, advancedInstallPath: helpPagePath('user/clusters/agent/install/index', { anchor: 'advanced-installation-method', }), + HELM_VERSION_POLICY_URL, components: { GlAlert, CodeBlock, GlFormInputGroup, GlLink, GlSprintf, + GlIcon, ModalCopyButton, }, inject: ['kasAddress', 'kasVersion'], @@ -77,6 +79,11 @@ export default { <p> {{ $options.i18n.basicInstallBody }} + <gl-sprintf :message="$options.i18n.helmVersionText"> + <template #link="{ content }" + ><gl-link :href="$options.HELM_VERSION_POLICY_URL" target="_blank" + >{{ content }} <gl-icon name="external-link" :size="12" /></gl-link></template + ></gl-sprintf> </p> <p class="gl-display-flex gl-align-items-flex-start"> diff --git a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue index bde76c46b4b..365e0384d87 100644 --- a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue +++ b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue @@ -1,23 +1,13 @@ <script> -import { - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - GlDropdownText, - GlSearchBoxByType, - GlSprintf, -} from '@gitlab/ui'; +import { GlCollapsibleListbox, GlButton, GlSprintf } from '@gitlab/ui'; import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '../constants'; export default { name: 'AvailableAgentsDropdown', i18n: I18N_AVAILABLE_AGENTS_DROPDOWN, components: { - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - GlDropdownText, - GlSearchBoxByType, + GlCollapsibleListbox, + GlButton, GlSprintf, }, props: { @@ -46,13 +36,21 @@ export default { return this.selectedAgent; }, + dropdownItems() { + return this.availableAgents.map((agent) => { + return { + value: agent, + text: agent, + }; + }); + }, shouldRenderCreateButton() { return this.searchTerm && !this.availableAgents.includes(this.searchTerm); }, filteredResults() { const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); - return this.availableAgents.filter((resultString) => - resultString.toLowerCase().includes(lowerCasedSearchTerm), + return this.dropdownItems.filter((item) => + item.value.toLowerCase().includes(lowerCasedSearchTerm), ); }, }, @@ -60,59 +58,48 @@ export default { selectAgent(agent) { this.$emit('agentSelected', agent); this.selectedAgent = agent; - this.clearSearch(); - }, - isSelected(agent) { - return this.selectedAgent === agent; - }, - clearSearch() { - this.searchTerm = ''; - }, - focusSearch() { - this.$refs.searchInput.focusInput(); - }, - handleShow() { - this.clearSearch(); - this.focusSearch(); + + this.$refs.dropdown.closeAndFocus(); }, onKeyEnter() { if (!this.searchTerm?.length) { return; } - this.$refs.dropdown.hide(); this.selectAgent(this.searchTerm); }, + searchAgent(searchQuery) { + this.searchTerm = searchQuery; + }, }, }; </script> <template> - <gl-dropdown ref="dropdown" :text="dropdownText" :loading="isRegistering" @shown="handleShow"> - <template #header> - <gl-search-box-by-type - ref="searchInput" - v-model.trim="searchTerm" - @keydown.enter.stop.prevent="onKeyEnter" - /> - </template> - <gl-dropdown-item - v-for="agent in filteredResults" - :key="agent" - :is-checked="isSelected(agent)" - is-check-item - @click="selectAgent(agent)" + <div @keydown.enter.stop.prevent="onKeyEnter"> + <gl-collapsible-listbox + ref="dropdown" + v-model="selectedAgent" + class="gl-w-full" + toggle-class="select-agent-dropdown" + :items="filteredResults" + :toggle-text="dropdownText" + :loading="isRegistering" + :searchable="true" + :no-results-text="$options.i18n.noResults" + @search="searchAgent" + @select="selectAgent" > - {{ agent }} - </gl-dropdown-item> - <gl-dropdown-text v-if="!filteredResults.length" ref="noMatchingResults">{{ - $options.i18n.noResults - }}</gl-dropdown-text> - <template v-if="shouldRenderCreateButton"> - <gl-dropdown-divider /> - <gl-dropdown-item data-testid="create-config-button" @click="selectAgent(searchTerm)"> - <gl-sprintf :message="$options.i18n.createButton"> - <template #searchTerm>{{ searchTerm }}</template> - </gl-sprintf> - </gl-dropdown-item> - </template> - </gl-dropdown> + <template v-if="shouldRenderCreateButton" #footer> + <gl-button + category="tertiary" + class="gl-justify-content-start! gl-border-t-1! gl-border-t-solid gl-border-t-gray-200 gl-pl-7! gl-rounded-top-left-none! gl-rounded-top-right-none!" + :class="{ 'gl-mt-3': !filteredResults.length }" + @click="selectAgent(searchTerm)" + > + <gl-sprintf :message="$options.i18n.createButton"> + <template #searchTerm>{{ searchTerm }}</template> + </gl-sprintf> + </gl-button> + </template> + </gl-collapsible-listbox> + </div> </template> diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index 7bc8a1a7304..615754459d6 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -4,6 +4,7 @@ import { helpPagePath } from '~/helpers/help_page_helper'; export const MAX_LIST_COUNT = 25; export const INSTALL_AGENT_MODAL_ID = 'install-agent'; export const ACTIVE_CONNECTION_TIME = 480000; +export const NAME_MAX_LENGTH = 50; export const CLUSTER_ERRORS = { default: { @@ -100,6 +101,9 @@ export const I18N_AGENT_TOKEN = { basicInstallBody: s__( 'ClusterAgents|From a terminal, connect to your cluster and run this command. The token is included in the command.', ), + helmVersionText: s__( + 'ClusterAgents|Use a Helm version compatible with your Kubernetes version (see %{linkStart}Helm version support policy%{linkEnd}).', + ), advancedInstallTitle: s__('ClusterAgents|Advanced installation methods'), advancedInstallBody: s__( @@ -107,6 +111,8 @@ export const I18N_AGENT_TOKEN = { ), }; +export const HELM_VERSION_POLICY_URL = 'https://helm.sh/docs/topics/version_skew/'; + export const I18N_AGENT_MODAL = { registerAgentButton: s__('ClusterAgents|Register'), close: __('Close'), diff --git a/app/assets/javascripts/constants.js b/app/assets/javascripts/constants.js new file mode 100644 index 00000000000..c56d45166a0 --- /dev/null +++ b/app/assets/javascripts/constants.js @@ -0,0 +1,3 @@ +import { s__ } from '~/locale'; + +export const MODIFIER_KEY = window.gl?.client?.isMac ? '⌘' : s__('KeyboardKey|Ctrl+'); diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue index a9668ebdb69..98b7203778f 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue @@ -166,9 +166,7 @@ export default { icon="arrow-left" @click.prevent.stop="showCustomLanguageInput = false" /> - <p - class="gl-text-center gl-new-dropdown-header-top gl-mb-0! gl-border-none! gl-pb-1!" - > + <p class="gl-text-center gl-dropdown-header-top gl-mb-0! gl-border-none! gl-pb-1!"> {{ __('Create custom type') }} </p> </div> diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 22381377389..53a37fc0c51 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -11,7 +11,7 @@ import FormattingBubbleMenu from './bubble_menus/formatting_bubble_menu.vue'; import CodeBlockBubbleMenu from './bubble_menus/code_block_bubble_menu.vue'; import LinkBubbleMenu from './bubble_menus/link_bubble_menu.vue'; import MediaBubbleMenu from './bubble_menus/media_bubble_menu.vue'; -import TopToolbar from './top_toolbar.vue'; +import FormattingToolbar from './formatting_toolbar.vue'; import LoadingIndicator from './loading_indicator.vue'; export default { @@ -20,7 +20,7 @@ export default { ContentEditorAlert, ContentEditorProvider, TiptapEditorContent, - TopToolbar, + FormattingToolbar, FormattingBubbleMenu, CodeBlockBubbleMenu, LinkBubbleMenu, @@ -57,6 +57,11 @@ export default { default: false, validator: (autofocus) => TIPTAP_AUTOFOCUS_OPTIONS.includes(autofocus), }, + useBottomToolbar: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -163,8 +168,8 @@ export default { class="md-area" :class="{ 'is-focused': focused }" > - <top-toolbar ref="toolbar" class="gl-mb-4" /> - <div class="gl-relative"> + <formatting-toolbar v-if="!useBottomToolbar" ref="toolbar" class="gl-border-b" /> + <div class="gl-relative gl-mt-4"> <formatting-bubble-menu /> <code-block-bubble-menu /> <link-bubble-menu /> @@ -176,6 +181,7 @@ export default { /> <loading-indicator v-if="isLoading" /> </div> + <formatting-toolbar v-if="useBottomToolbar" ref="toolbar" class="gl-border-t" /> </div> </div> </content-editor-provider> diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue index 460368b6a11..8a25ad3fd96 100644 --- a/app/assets/javascripts/content_editor/components/top_toolbar.vue +++ b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue @@ -24,9 +24,7 @@ export default { }; </script> <template> - <div - class="gl-display-flex gl-flex-wrap gl-pb-3 gl-pt-3 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200" - > + <div class="gl-display-flex gl-flex-wrap gl-pb-3 gl-pt-3"> <toolbar-text-style-dropdown data-testid="text-styles" class="gl-mr-3" diff --git a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue index 001b34a00fa..37e6ef61d50 100644 --- a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue @@ -210,10 +210,10 @@ export default { <template> <ul :class="{ show: items.length > 0 }" - class="gl-new-dropdown dropdown-menu gl-relative" + class="gl-dropdown dropdown-menu gl-relative" data-testid="content-editor-suggestions-dropdown" > - <div class="gl-new-dropdown-inner gl-overflow-y-auto"> + <div class="gl-dropdown-inner gl-overflow-y-auto"> <gl-dropdown-item v-for="(item, index) in items" ref="dropdownItems" diff --git a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue index 6bb122153ef..93b31ea7d20 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue @@ -58,6 +58,9 @@ export default { right lazy > + <gl-dropdown-item @click="insert('comment')"> + {{ __('Comment') }} + </gl-dropdown-item> <gl-dropdown-item @click="insert('codeBlock')"> {{ __('Code block') }} </gl-dropdown-item> diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js index 27432b1e18b..1d85bfcc965 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -23,6 +23,10 @@ export default CodeBlockLowlight.extend({ // eslint-disable-next-line @gitlab/require-i18n-strings default: 'code highlight', }, + langParams: { + default: null, + parseHTML: (element) => element.dataset.langParams, + }, }; }, addInputRules() { diff --git a/app/assets/javascripts/content_editor/extensions/comment.js b/app/assets/javascripts/content_editor/extensions/comment.js new file mode 100644 index 00000000000..8e247e552a3 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/comment.js @@ -0,0 +1,49 @@ +import { Node, textblockTypeInputRule } from '@tiptap/core'; + +export const commentInputRegex = /^<!--[\s\n]$/; + +export default Node.create({ + name: 'comment', + content: 'text*', + marks: '', + group: 'block', + code: true, + isolating: true, + defining: true, + + parseHTML() { + return [ + { + tag: 'comment', + preserveWhitespace: 'full', + getContent(element, schema) { + const node = schema.node('paragraph', {}, [ + schema.text( + element.textContent.replace(/&#x([0-9A-F]{2,4});/gi, (_, code) => + String.fromCharCode(parseInt(code, 16)), + ) || ' ', + ), + ]); + return node.content; + }, + }, + ]; + }, + + renderHTML() { + return [ + 'pre', + { class: 'gl-p-0 gl-border-0 gl-bg-transparent gl-text-gray-300' }, + ['span', { class: 'content-editor-comment' }, 0], + ]; + }, + + addInputRules() { + return [ + textblockTypeInputRule({ + find: commentInputRegex, + type: this.type, + }), + ]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js index 65849ec4d0d..fc4c108b773 100644 --- a/app/assets/javascripts/content_editor/extensions/image.js +++ b/app/assets/javascripts/content_editor/extensions/image.js @@ -52,6 +52,22 @@ export default Image.extend({ return img.getAttribute('title'); }, }, + width: { + default: null, + parseHTML: (element) => { + const img = resolveImageEl(element); + + return img.getAttribute('width'); + }, + }, + height: { + default: null, + parseHTML: (element) => { + const img = resolveImageEl(element); + + return img.getAttribute('height'); + }, + }, isReference: { default: false, renderHTML: () => '', @@ -76,6 +92,8 @@ export default Image.extend({ src: HTMLAttributes.src, alt: HTMLAttributes.alt, title: HTMLAttributes.title, + width: HTMLAttributes.width, + height: HTMLAttributes.height, }, ]; }, diff --git a/app/assets/javascripts/content_editor/extensions/reference_label.js b/app/assets/javascripts/content_editor/extensions/reference_label.js index 716e191c3d5..9dff0b7a689 100644 --- a/app/assets/javascripts/content_editor/extensions/reference_label.js +++ b/app/assets/javascripts/content_editor/extensions/reference_label.js @@ -1,5 +1,5 @@ import { VueNodeViewRenderer } from '@tiptap/vue-2'; -import { SCOPED_LABEL_DELIMITER } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; +import { SCOPED_LABEL_DELIMITER } from '~/sidebar/components/labels/labels_select_widget/constants'; import LabelWrapper from '../components/wrappers/label.vue'; import Reference from './reference'; diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js index ba9ce705c62..61c6be574d0 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -10,6 +10,7 @@ import BulletList from '../extensions/bullet_list'; import Code from '../extensions/code'; import CodeBlockHighlight from '../extensions/code_block_highlight'; import ColorChip from '../extensions/color_chip'; +import Comment from '../extensions/comment'; import DescriptionItem from '../extensions/description_item'; import DescriptionList from '../extensions/description_list'; import Details from '../extensions/details'; @@ -100,6 +101,7 @@ export const createContentEditor = ({ BulletList, Code, ColorChip, + Comment, CodeBlockHighlight, DescriptionItem, DescriptionList, diff --git a/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js index fa46bd9ff81..796dc06ad93 100644 --- a/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js +++ b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js @@ -1,4 +1,5 @@ import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model'; +import { replaceCommentsWith } from '~/lib/utils/dom_utils'; export default ({ render }) => { /** @@ -22,7 +23,9 @@ export default ({ render }) => { if (!html) return {}; const parser = new DOMParser(); - const { body } = parser.parseFromString(html, 'text/html'); + const { body } = parser.parseFromString(`<body>${html}</body>`, 'text/html'); + + replaceCommentsWith(body, 'comment'); // append original source as a comment that nodes can access body.append(document.createComment(markdown)); diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index 958c27c281a..4e29f85004b 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -12,6 +12,7 @@ import DescriptionItem from '../extensions/description_item'; import DescriptionList from '../extensions/description_list'; import Details from '../extensions/details'; import DetailsContent from '../extensions/details_content'; +import Comment from '../extensions/comment'; import Diagram from '../extensions/diagram'; import Emoji from '../extensions/emoji'; import Figure from '../extensions/figure'; @@ -50,6 +51,7 @@ import Text from '../extensions/text'; import Video from '../extensions/video'; import WordBreak from '../extensions/word_break'; import { + renderComment, renderCodeBlock, renderHardBreak, renderTable, @@ -130,6 +132,7 @@ const defaultSerializerConfig = { }), [BulletList.name]: preserveUnchanged(renderBulletList), [CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock), + [Comment.name]: renderComment, [Diagram.name]: preserveUnchanged(renderCodeBlock), [DescriptionList.name]: renderHTMLNode('dl', true), [DescriptionItem.name]: (state, node, parent, index) => { diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 5c0cb21075a..131c79357bf 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -308,7 +308,7 @@ export function renderHardBreak(state, node, parent, index) { } export function renderImage(state, node) { - const { alt, canonicalSrc, src, title, isReference } = node.attrs; + const { alt, canonicalSrc, src, title, width, height, isReference } = node.attrs; if (isString(src) || isString(canonicalSrc)) { const quotedTitle = title ? ` ${state.quote(title)}` : ''; @@ -316,7 +316,17 @@ export function renderImage(state, node) { ? `[${canonicalSrc}]` : `(${state.esc(canonicalSrc || src)}${quotedTitle})`; - state.write(`![${state.esc(alt || '')}]${sourceExpression}`); + const sizeAttributes = []; + if (width) { + sizeAttributes.push(`width=${JSON.stringify(width)}`); + } + if (height) { + sizeAttributes.push(`height=${JSON.stringify(height)}`); + } + + const attributes = sizeAttributes.length ? `{${sizeAttributes.join(' ')}}` : ''; + + state.write(`![${state.esc(alt || '')}]${sourceExpression}${attributes}`); } } @@ -324,8 +334,19 @@ export function renderPlayable(state, node) { renderImage(state, node); } +export function renderComment(state, node) { + state.text('<!--'); + state.text(node.textContent); + state.text('-->'); + state.closeBlock(node); +} + export function renderCodeBlock(state, node) { - state.write(`\`\`\`${node.attrs.language || ''}\n`); + state.write( + `\`\`\`${ + (node.attrs.language || '') + (node.attrs.langParams ? `:${node.attrs.langParams}` : '') + }\n`, + ); state.text(node.textContent, false); state.ensureNewLine(); state.write('```'); diff --git a/app/assets/javascripts/crm/components/form.vue b/app/assets/javascripts/crm/components/crm_form.vue index ea6a6892bbd..ea6a6892bbd 100644 --- a/app/assets/javascripts/crm/components/form.vue +++ b/app/assets/javascripts/crm/components/crm_form.vue diff --git a/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue b/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue index b29089519e2..a851c7a9e85 100644 --- a/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue +++ b/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue @@ -2,7 +2,7 @@ import { s__, __ } from '~/locale'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPE_CRM_CONTACT, TYPE_GROUP } from '~/graphql_shared/constants'; -import ContactForm from '../../components/form.vue'; +import CrmForm from '../../components/crm_form.vue'; import getGroupOrganizationsQuery from '../../organizations/components/graphql/get_group_organizations.query.graphql'; import getGroupContactsQuery from './graphql/get_group_contacts.query.graphql'; import createContactMutation from './graphql/create_contact.mutation.graphql'; @@ -10,7 +10,7 @@ import updateContactMutation from './graphql/update_contact.mutation.graphql'; export default { components: { - ContactForm, + CrmForm, }, inject: ['groupFullPath', 'groupId'], props: { @@ -111,7 +111,7 @@ export default { </script> <template> - <contact-form + <crm-form :drawer-open="true" :get-query="getQuery" get-query-node-path="group.contacts" diff --git a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue index 32900d45f22..01bff4b69d6 100644 --- a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue +++ b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue @@ -2,14 +2,14 @@ import { s__, __ } from '~/locale'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPE_CRM_ORGANIZATION, TYPE_GROUP } from '~/graphql_shared/constants'; -import OrganizationForm from '../../components/form.vue'; +import CrmForm from '../../components/crm_form.vue'; import getGroupOrganizationsQuery from './graphql/get_group_organizations.query.graphql'; import createOrganizationMutation from './graphql/create_organization.mutation.graphql'; import updateOrganizationMutation from './graphql/update_organization.mutation.graphql'; export default { components: { - OrganizationForm, + CrmForm, }, inject: ['groupFullPath', 'groupId'], props: { @@ -73,7 +73,7 @@ export default { </script> <template> - <organization-form + <crm-form :drawer-open="true" :get-query="getQuery" get-query-node-path="group.organizations" diff --git a/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue b/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue index 411e482b0ce..c6aeb6c726d 100644 --- a/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue +++ b/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue @@ -185,7 +185,6 @@ export default { name="prometheus_metric[title]" class="form-control" :placeholder="s__('Metrics|e.g. Throughput')" - data-qa-selector="custom_metric_prometheus_title_field" required /> <span class="form-text text-muted">{{ s__('Metrics|Used as a title for the chart') }}</span> @@ -209,7 +208,6 @@ export default { <gl-form-input id="prometheus_metric_query" v-model.trim="query" - data-qa-selector="custom_metric_prometheus_query_field" name="prometheus_metric[query]" class="form-control" :placeholder="s__('Metrics|e.g. rate(http_requests_total[5m])')" @@ -247,7 +245,6 @@ export default { <gl-form-input id="prometheus_metric_y_label" v-model="yLabel" - data-qa-selector="custom_metric_prometheus_y_label_field" name="prometheus_metric[y_label]" class="form-control" :placeholder="s__('Metrics|e.g. Requests/second')" @@ -267,7 +264,6 @@ export default { <gl-form-input id="prometheus_metric_unit" v-model="unit" - data-qa-selector="custom_metric_prometheus_unit_label_field" name="prometheus_metric[unit]" class="form-control" :placeholder="s__('Metrics|e.g. req/sec')" @@ -282,7 +278,6 @@ export default { <gl-form-input id="prometheus_metric_legend" v-model="legend" - data-qa-selector="custom_metric_prometheus_legend_label_field" name="prometheus_metric[legend]" class="form-control" :placeholder="s__('Metrics|e.g. HTTP requests')" diff --git a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue index 81d74c64124..48ab9ce0a3c 100644 --- a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue +++ b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue @@ -13,27 +13,7 @@ import { createAlert, VARIANT_INFO } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { formatDate } from '~/lib/utils/datetime_utility'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import { s__ } from '~/locale'; - -function defaultData() { - return { - expiresAt: null, - name: '', - newTokenDetails: null, - readRepository: false, - writeRepository: false, - readRegistry: false, - writeRegistry: false, - readPackageRegistry: false, - writePackageRegistry: false, - username: '', - placeholders: { - link: { link: ['link_start', 'link_end'] }, - i: { i: ['i_start', 'i_end'] }, - code: { code: ['code_start', 'code_end'] }, - }, - }; -} +import translations from '../deploy_token_translations'; export default { components: { @@ -72,45 +52,9 @@ export default { }, data() { - return defaultData(); - }, - translations: { - addTokenButton: s__('DeployTokens|Create deploy token'), - addTokenExpiryLabel: s__('DeployTokens|Expiration date (optional)'), - addTokenExpiryDescription: s__( - 'DeployTokens|Enter an expiration date for your token. Defaults to never expire.', - ), - addTokenHeader: s__('DeployTokens|New deploy token'), - addTokenDescription: s__( - 'DeployTokens|Create a new deploy token for all projects in this group. %{link_start}What are deploy tokens?%{link_end}', - ), - addTokenNameLabel: s__('DeployTokens|Name'), - addTokenNameDescription: s__('DeployTokens|Enter a unique name for your deploy token.'), - addTokenScopesLabel: s__('DeployTokens|Scopes (select at least one)'), - addTokenUsernameDescription: s__( - 'DeployTokens|Enter a username for your token. Defaults to %{code_start}gitlab+deploy-token-{n}%{code_end}.', - ), - addTokenUsernameLabel: s__('DeployTokens|Username (optional)'), - newTokenCopyMessage: s__('DeployTokens|Copy deploy token'), - newProjectTokenCreated: s__('DeployTokens|Your new project deploy token has been created.'), - newGroupTokenCreated: s__('DeployTokens|Your new group deploy token has been created.'), - newTokenDescription: s__( - 'DeployTokens|Use this token as a password. Save it. This password can %{i_start}not%{i_end} be recovered.', - ), - newTokenMessage: s__('DeployTokens|Your New Deploy Token'), - newTokenUsernameCopy: s__('DeployTokens|Copy username'), - newTokenUsernameDescription: s__( - 'DeployTokens|This username supports access. %{link_start}What kind of access?%{link_end}', - ), - readRepositoryHelp: s__('DeployTokens|Allows read-only access to the repository.'), - readRegistryHelp: s__('DeployTokens|Allows read-only access to registry images.'), - writeRegistryHelp: s__('DeployTokens|Allows read and write access to registry images.'), - readPackageRegistryHelp: s__('DeployTokens|Allows read-only access to the package registry.'), - writePackageRegistryHelp: s__( - 'DeployTokens|Allows read and write access to the package registry.', - ), - createTokenFailedAlert: s__('DeployTokens|Failed to create a new deployment token'), + return this.defaultData(); }, + translations, computed: { formattedExpiryDate() { return this.expiresAt ? formatDate(this.expiresAt, 'yyyy-mm-dd') : ''; @@ -122,20 +66,78 @@ export default { }, }, methods: { + defaultData() { + return { + expiresAt: null, + name: '', + newTokenDetails: null, + readRepository: false, + writeRepository: false, + readRegistry: false, + writeRegistry: false, + readPackageRegistry: false, + writePackageRegistry: false, + scopes: [ + { + id: 'deploy_token_read_repository', + isShown: true, + value: false, + helpText: this.$options.translations.readRepositoryHelp, + scopeName: 'read_repository', + }, + { + id: 'deploy_token_read_registry', + isShown: this.$props.containerRegistryEnabled, + value: false, + helpText: this.$options.translations.readRegistryHelp, + scopeName: 'read_registry', + }, + { + id: 'deploy_token_write_registry', + isShown: this.$props.containerRegistryEnabled, + value: false, + helpText: this.$options.translations.writeRegistryHelp, + scopeName: 'write_registry', + }, + { + id: 'deploy_token_read_package_registry', + isShown: this.$props.packagesRegistryEnabled, + value: false, + helpText: this.$options.translations.readPackageRegistryHelp, + scopeName: 'read_package_registry', + }, + { + id: 'deploy_token_write_package_registry', + isShown: this.$props.packagesRegistryEnabled, + value: false, + helpText: this.$options.translations.writePackageRegistryHelp, + scopeName: 'write_package_registry', + }, + ], + username: '', + placeholders: { + link: { link: ['link_start', 'link_end'] }, + i: { i: ['i_start', 'i_end'] }, + code: { code: ['code_start', 'code_end'] }, + }, + }; + }, createDeployToken() { + const scopes = {}; + this.scopes.forEach((scope) => { + scopes[scope.scopeName] = scope.value; + }); + const body = { + deploy_token: { + expires_at: this.expiresAt, + name: this.name, + username: this.username, + ...scopes, + }, + }; + return axios - .post(this.createNewTokenPath, { - deploy_token: { - expires_at: this.expiresAt, - name: this.name, - read_repository: this.readRepository, - read_registry: this.readRegistry, - write_registry: this.writeRegistry, - read_package_registry: this.readPackageRegistry, - write_package_registry: this.writePackageRegistry, - username: this.username, - }, - }) + .post(this.createNewTokenPath, body) .then((response) => { this.newTokenDetails = response.data; this.resetData(); @@ -152,7 +154,7 @@ export default { }); }, resetData() { - const newData = defaultData(); + const newData = this.defaultData(); delete newData.newTokenDetails; Object.keys(newData).forEach((k) => { this[k] = newData[k]; @@ -269,55 +271,19 @@ export default { > <div id="deploy-token-scopes"> <!-- eslint-disable @gitlab/vue-require-i18n-strings --> - <gl-form-checkbox - id="deploy_token_read_repository" - v-model="readRepository" - name="deploy_token_read_repository" - data-qa-selector="deploy_token_read_repository_checkbox" - > - read_repository - <template #help>{{ $options.translations.readRepositoryHelp }}</template> - </gl-form-checkbox> - <gl-form-checkbox - v-if="containerRegistryEnabled" - id="deploy_token_read_registry" - v-model="readRegistry" - name="deploy_token_read_registry" - data-qa-selector="deploy_token_read_registry_checkbox" - > - read_registry - <template #help>{{ $options.translations.readRegistryHelp }}</template> - </gl-form-checkbox> - <gl-form-checkbox - v-if="containerRegistryEnabled" - id="deploy_token_write_registry" - v-model="writeRegistry" - name="deploy_token_write_registry" - data-qa-selector="deploy_token_write_registry_checkbox" - > - write_registry - <template #help>{{ $options.translations.writeRegistryHelp }}</template> - </gl-form-checkbox> - <gl-form-checkbox - v-if="packagesRegistryEnabled" - id="deploy_token_read_package_registry" - v-model="readPackageRegistry" - name="deploy_token_read_package_registry" - data-qa-selector="deploy_token_read_package_registry_checkbox" - > - read_package_registry - <template #help>{{ $options.translations.readPackageRegistryHelp }}</template> - </gl-form-checkbox> - <gl-form-checkbox - v-if="packagesRegistryEnabled" - id="deploy_token_write_package_registry" - v-model="writePackageRegistry" - name="deploy_token_write_package_registry" - data-qa-selector="deploy_token_write_package_registry_checkbox" - > - write_package_registry - <template #help>{{ $options.translations.writePackageRegistryHelp }}</template> - </gl-form-checkbox> + <template v-for="scope in scopes"> + <gl-form-checkbox + v-if="scope.isShown" + :id="scope.id" + :key="scope.id" + v-model="scope.value" + :name="scope.id" + :data-qa-selector="`${scope.id}_checkbox`" + > + {{ scope.scopeName }} + <template #help>{{ scope.helpText }}</template> + </gl-form-checkbox> + </template> <!-- eslint-enable @gitlab/vue-require-i18n-strings --> </div> </gl-form-group> diff --git a/app/assets/javascripts/deploy_tokens/deploy_token_translations.js b/app/assets/javascripts/deploy_tokens/deploy_token_translations.js new file mode 100644 index 00000000000..3767e9e6170 --- /dev/null +++ b/app/assets/javascripts/deploy_tokens/deploy_token_translations.js @@ -0,0 +1,41 @@ +import { s__ } from '~/locale'; + +const translations = { + addTokenButton: s__('DeployTokens|Create deploy token'), + addTokenExpiryLabel: s__('DeployTokens|Expiration date (optional)'), + addTokenExpiryDescription: s__( + 'DeployTokens|Enter an expiration date for your token. Defaults to never expire.', + ), + addTokenHeader: s__('DeployTokens|New deploy token'), + addTokenDescription: s__( + 'DeployTokens|Create a new deploy token for all projects in this group. %{link_start}What are deploy tokens?%{link_end}', + ), + addTokenNameLabel: s__('DeployTokens|Name'), + addTokenNameDescription: s__('DeployTokens|Enter a unique name for your deploy token.'), + addTokenScopesLabel: s__('DeployTokens|Scopes (select at least one)'), + addTokenUsernameDescription: s__( + 'DeployTokens|Enter a username for your token. Defaults to %{code_start}gitlab+deploy-token-{n}%{code_end}.', + ), + addTokenUsernameLabel: s__('DeployTokens|Username (optional)'), + newTokenCopyMessage: s__('DeployTokens|Copy deploy token'), + newProjectTokenCreated: s__('DeployTokens|Your new project deploy token has been created.'), + newGroupTokenCreated: s__('DeployTokens|Your new group deploy token has been created.'), + newTokenDescription: s__( + 'DeployTokens|Use this token as a password. Save it. This password can %{i_start}not%{i_end} be recovered.', + ), + newTokenMessage: s__('DeployTokens|Your New Deploy Token'), + newTokenUsernameCopy: s__('DeployTokens|Copy username'), + newTokenUsernameDescription: s__( + 'DeployTokens|This username supports access. %{link_start}What kind of access?%{link_end}', + ), + readRepositoryHelp: s__('DeployTokens|Allows read-only access to the repository.'), + readRegistryHelp: s__('DeployTokens|Allows read-only access to registry images.'), + writeRegistryHelp: s__('DeployTokens|Allows read and write access to registry images.'), + readPackageRegistryHelp: s__('DeployTokens|Allows read-only access to the package registry.'), + writePackageRegistryHelp: s__( + 'DeployTokens|Allows read and write access to the package registry.', + ), + createTokenFailedAlert: s__('DeployTokens|Failed to create a new deployment token'), +}; + +export default translations; diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/render.js b/app/assets/javascripts/deprecated_jquery_dropdown/render.js index 0f612989bb4..97698d55011 100644 --- a/app/assets/javascripts/deprecated_jquery_dropdown/render.js +++ b/app/assets/javascripts/deprecated_jquery_dropdown/render.js @@ -149,7 +149,7 @@ function renderLink(row, data, { options, group, index }) { } function getOptionRenderer({ options, instance }) { - return options.renderRow && ((li, data) => options.renderRow(data, instance)); + return options.renderRow && ((li, data, params) => options.renderRow(data, instance, params)); } function getRenderer(data, params) { diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js index 2ac62b9b927..c090a66a69d 100644 --- a/app/assets/javascripts/deprecated_notes.js +++ b/app/assets/javascripts/deprecated_notes.js @@ -15,6 +15,7 @@ import Autosize from 'autosize'; import $ from 'jquery'; import { escape, uniqueId } from 'lodash'; import Vue from 'vue'; +import { createAlert, VARIANT_INFO } from '~/flash'; import '~/lib/utils/jquery_at_who'; import AjaxCache from '~/lib/utils/ajax_cache'; import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; @@ -24,7 +25,6 @@ import * as constants from '~/notes/constants'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import Autosave from './autosave'; import loadAwardsHandler from './awards_handler'; -import createFlash from './flash'; import { defaultAutocompleteConfig } from './gfm_auto_complete'; import GLForm from './gl_form'; import axios from './lib/utils/axios_utils'; @@ -40,6 +40,7 @@ import { localTimeAgo } from './lib/utils/datetime_utility'; import { getLocationHash } from './lib/utils/url_utility'; import { sprintf, s__, __ } from './locale'; import TaskList from './task_list'; +import '~/behaviors/markdown/init_gfm'; window.autosize = Autosize; @@ -81,7 +82,7 @@ export default class Notes { this.keydownNoteText = this.keydownNoteText.bind(this); this.toggleCommitList = this.toggleCommitList.bind(this); this.postComment = this.postComment.bind(this); - this.clearFlashWrapper = this.clearFlash.bind(this); + this.clearAlertWrapper = this.clearAlert.bind(this); this.onHashChange = this.onHashChange.bind(this); this.notes_url = notes_url; @@ -431,9 +432,9 @@ export default class Notes { if (noteEntity.commands_changes && Object.keys(noteEntity.commands_changes).length > 0) { $notesList.find('.system-note.being-posted').remove(); } - this.addFlash({ + this.addAlert({ message: noteEntity.errors.commands_only, - type: 'notice', + variant: VARIANT_INFO, parent: this.parentTimeline.get(0), }); this.refresh(); @@ -656,7 +657,7 @@ export default class Notes { } else if ($form.hasClass('js-discussion-note-form')) { formParentTimeline = $form.closest('.discussion-notes').find('.notes'); } - return this.addFlash({ + return this.addAlert({ message: __( 'Your comment could not be submitted! Please check your network connection and try again.', ), @@ -665,7 +666,7 @@ export default class Notes { } updateNoteError() { - createFlash({ + createAlert({ message: __( 'Your comment could not be updated! Please check your network connection and try again.', ), @@ -1338,15 +1339,12 @@ export default class Notes { }); } - addFlash(...flashParams) { - this.flashContainer = createFlash(...flashParams); + addAlert(...alertParams) { + this.alert = createAlert(...alertParams); } - clearFlash() { - if (this.flashContainer) { - this.flashContainer.style.display = 'none'; - this.flashContainer = null; - } + clearAlert() { + this.alert?.dismiss(); } cleanForm($form) { @@ -1535,7 +1533,7 @@ export default class Notes { * b. Reset comment form to original state. * b) If request failed * 1. Remove placeholder element - * 2. Show error Flash message about failure + * 2. Show error alert message about failure */ postComment(e) { e.preventDefault(); @@ -1645,7 +1643,7 @@ export default class Notes { } // Clear previous form errors - this.clearFlashWrapper(); + this.clearAlertWrapper(); // Check if this was discussion comment if (isDiscussionForm) { diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue index a4430b15752..3091c6703b4 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue @@ -4,7 +4,7 @@ import { ApolloMutation } from 'vue-apollo'; import { createAlert } from '~/flash'; import { s__ } from '~/locale'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; -import { updateGlobalTodoCount } from '~/vue_shared/components/sidebar/todo_toggle/utils'; +import { updateGlobalTodoCount } from '~/sidebar/utils'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue'; import { isLoggedIn } from '~/lib/utils/common_utils'; diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue index e629f74ba02..af4bf7eb14d 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue @@ -1,13 +1,7 @@ <script> -import { - GlAvatar, - GlAvatarLink, - GlButton, - GlLink, - GlSafeHtmlDirective, - GlTooltipDirective, -} from '@gitlab/ui'; +import { GlAvatar, GlAvatarLink, GlButton, GlLink, GlTooltipDirective } from '@gitlab/ui'; import { ApolloMutation } from 'vue-apollo'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; @@ -33,7 +27,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { note: { diff --git a/app/assets/javascripts/design_management/components/design_todo_button.vue b/app/assets/javascripts/design_management/components/design_todo_button.vue index 013dd1d89f3..a1a23d61093 100644 --- a/app/assets/javascripts/design_management/components/design_todo_button.vue +++ b/app/assets/javascripts/design_management/components/design_todo_button.vue @@ -1,6 +1,6 @@ <script> import todoMarkDoneMutation from '~/graphql_shared/mutations/todo_mark_done.mutation.graphql'; -import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue'; +import TodoButton from '~/sidebar/components/todo_toggle/todo_button.vue'; import createDesignTodoMutation from '../graphql/mutations/create_design_todo.mutation.graphql'; import getDesignQuery from '../graphql/queries/get_design.query.graphql'; import allVersionsMixin from '../mixins/all_versions'; diff --git a/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue index f10545faea6..c96487d0d08 100644 --- a/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue +++ b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui'; +import { GlAvatar, GlCollapsibleListbox } from '@gitlab/ui'; import defaultAvatarUrl from 'images/no_avatar.png'; import { __, sprintf } from '~/locale'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -8,13 +8,19 @@ import { findVersionId } from '../../utils/design_management_utils'; export default { components: { - GlDropdown, - GlDropdownItem, - GlSprintf, + GlAvatar, + GlCollapsibleListbox, TimeAgo, }, mixins: [allVersionsMixin], computed: { + allVersionsList() { + return this.allVersions.map(({ id, ...item }, index) => ({ + value: id, + index, + ...item, + })); + }, queryVersion() { return this.$route.query.version; }, @@ -29,17 +35,11 @@ export default { // then return the latest version (index 0) return idx !== -1 ? idx : 0; }, - currentVersionId() { - if (this.queryVersion) return this.queryVersion; - - const currentVersion = this.allVersions[this.currentVersionIdx]; - return this.findVersionId(currentVersion.id); - }, dropdownText() { if (this.isLatestVersion) { return __('Showing latest version'); } - // allVersions is sorted in reverse chronological order (latest first) + // allVersions is sorted in reverse chronological order (the latest first) const currentVersionNumber = this.allVersions.length - this.currentVersionIdx; return sprintf(__('Showing version #%{versionNumber}'), { @@ -55,47 +55,49 @@ export default { query: { version: this.findVersionId(versionId) }, }); }, - versionText(versionId) { - if (this.findVersionId(versionId) === this.latestVersionId) { - return __('Version %{versionNumber} (latest)'); - } - return __('Version %{versionNumber}'); + versionText(item) { + const versionNumber = this.allVersions.length - item.index; + const message = + this.findVersionId(item.value) === this.latestVersionId + ? __('Version %{versionNumber} (latest)') + : __('Version %{versionNumber}'); + return sprintf(message, { versionNumber }); }, getAvatarUrl(version) { return version?.author?.avatarUrl || defaultAvatarUrl; }, + getAuthorName(author) { + return author?.name; + }, }, }; </script> <template> - <gl-dropdown :text="dropdownText" size="small"> - <gl-dropdown-item - v-for="(version, index) in allVersions" - :key="version.id" - is-check-item - is-check-centered - :is-checked="findVersionId(version.id) === currentVersionId" - :avatar-url="getAvatarUrl(version)" - @click="routeToVersion(version.id)" - > - <strong> - <gl-sprintf :message="versionText(version.id)"> - <template #versionNumber> - {{ allVersions.length - index }} - </template> - </gl-sprintf> - </strong> - - <div v-if="version.author" class="gl-text-gray-600 gl-mt-1"> - <div>{{ version.author.name }}</div> - <time-ago - v-if="version.createdAt" - class="text-1" - :time="version.createdAt" - tooltip-placement="bottom" - /> - </div> - </gl-dropdown-item> - </gl-dropdown> + <gl-collapsible-listbox + is-check-centered + :items="allVersionsList" + :toggle-text="dropdownText" + :selected="designsVersion" + size="small" + @select="routeToVersion" + > + <template #list-item="{ item }"> + <span class="gl-display-flex gl-align-items-center"> + <gl-avatar :alt="getAuthorName(item.author)" :size="32" :src="getAvatarUrl(item)" /> + <span class="gl-display-flex gl-flex-direction-column"> + <span class="gl-font-weight-bold">{{ versionText(item) }}</span> + <span v-if="item.author" class="gl-text-gray-600 gl-mt-1"> + <span class="gl-display-block">{{ getAuthorName(item.author) }}</span> + <time-ago + v-if="item.createdAt" + class="text-1" + :time="item.createdAt" + tooltip-placement="bottom" + /> + </span> + </span> + </span> + </template> + </gl-collapsible-listbox> </template> diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue index d4c177e2e5f..f448e2f9e3d 100644 --- a/app/assets/javascripts/design_management/pages/design/index.vue +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -6,7 +6,7 @@ import { ApolloMutation } from 'vue-apollo'; import { keysFor, ISSUE_CLOSE_DESIGN } from '~/behaviors/shortcuts/keybindings'; import { createAlert } from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; -import { updateGlobalTodoCount } from '~/vue_shared/components/sidebar/todo_toggle/utils'; +import { updateGlobalTodoCount } from '~/sidebar/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import DesignDestroyer from '../../components/design_destroyer.vue'; import DesignReplyForm from '../../components/design_notes/design_reply_form.vue'; diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index 5a45797ed98..1857ff557e6 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -1,5 +1,6 @@ <script> -import { GlButtonGroup, GlButton, GlTooltipDirective, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; @@ -32,7 +33,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [glFeatureFlagsMixin()], props: { diff --git a/app/assets/javascripts/diffs/components/diff_code_quality.vue b/app/assets/javascripts/diffs/components/diff_code_quality.vue index 8498724740f..11aa856619b 100644 --- a/app/assets/javascripts/diffs/components/diff_code_quality.vue +++ b/app/assets/javascripts/diffs/components/diff_code_quality.vue @@ -1,8 +1,12 @@ <script> import { GlButton, GlIcon } from '@gitlab/ui'; -import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/reports/codequality_report/constants'; +import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants'; +import { NEW_CODE_QUALITY_FINDINGS } from '../i18n'; export default { + i18n: { + newFindings: NEW_CODE_QUALITY_FINDINGS, + }, components: { GlButton, GlIcon }, props: { codeQuality: { @@ -22,22 +26,33 @@ export default { </script> <template> - <div data-testid="diff-codequality" class="gl-relative"> - <ul - class="gl-list-style-none gl-mb-0 gl-p-0 codequality-findings-list gl-border-top-1 gl-border-bottom-1 gl-bg-gray-10" + <div + data-testid="diff-codequality" + class="gl-relative codequality-findings-list gl-border-top-1 gl-border-bottom-1 gl-bg-gray-10 gl-pl-5 gl-pt-4 gl-pb-4" + > + <h4 + data-testid="diff-codequality-findings-heading" + class="gl-mt-0 gl-mb-0 gl-font-base gl-font-regular" > + {{ $options.i18n.newFindings }} + </h4> + <ul class="gl-list-style-none gl-mb-0 gl-p-0"> <li v-for="finding in codeQuality" :key="finding.description" - class="gl-pt-1 gl-pb-1 gl-pl-3 gl-border-solid gl-border-bottom-0 gl-border-right-0 gl-border-1 gl-border-gray-100 gl-font-regular" + class="gl-pt-1 gl-pb-1 gl-font-regular gl-display-flex" > - <gl-icon - :size="12" - :name="severityIcon(finding.severity)" - :class="severityClass(finding.severity)" - class="codequality-severity-icon" - /> - {{ finding.description }} + <span class="gl-mr-3"> + <gl-icon + :size="12" + :name="severityIcon(finding.severity)" + :class="severityClass(finding.severity)" + class="codequality-severity-icon" + /> + </span> + <span> + <span class="severity-copy">{{ finding.severity }}</span> - {{ finding.description }} + </span> </li> </ul> <gl-button diff --git a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue index 3766c125325..8b747aa08dd 100644 --- a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue +++ b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue @@ -1,13 +1,18 @@ <script> +import { GlButton } from '@gitlab/ui'; import { mapGetters } from 'vuex'; -import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; import NoteSignedOutWidget from '~/notes/components/note_signed_out_widget.vue'; +import { START_THREAD } from '../i18n'; + export default { name: 'DiffDiscussionReply', + i18n: { + START_THREAD, + }, components: { + GlButton, NoteSignedOutWidget, - ReplyPlaceholder, }, props: { hasForm: { @@ -34,11 +39,9 @@ export default { <template v-if="userCanReply"> <slot v-if="hasForm" name="form"></slot> <template v-else-if="renderReplyPlaceholder"> - <reply-placeholder - :placeholder-text="__('Start a new discussion…')" - :label-text="__('New discussion')" - @focus="$emit('showNewDiscussionForm')" - /> + <gl-button @click="$emit('showNewDiscussionForm')"> + {{ $options.i18n.START_THREAD }} + </gl-button> </template> </template> <note-signed-out-widget v-else /> diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue index b2098b9e82d..8fcbc4b5cce 100644 --- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue @@ -1,6 +1,7 @@ <script> -import { GlTooltipDirective, GlSafeHtmlDirective, GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { GlTooltipDirective, GlIcon, GlLoadingIcon } from '@gitlab/ui'; import { mapActions } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { createAlert } from '~/flash'; import { s__, sprintf } from '~/locale'; import { UNFOLD_COUNT, INLINE_DIFF_LINES_KEY } from '../constants'; @@ -21,7 +22,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { file: { diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 8f041d1e670..564f776edd2 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -1,13 +1,8 @@ <script> -import { - GlButton, - GlLoadingIcon, - GlSafeHtmlDirective as SafeHtml, - GlSprintf, - GlAlert, -} from '@gitlab/ui'; +import { GlButton, GlLoadingIcon, GlSprintf, GlAlert } from '@gitlab/ui'; import { escape } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { IdState } from 'vendor/vue-virtual-scroller'; import DiffContent from 'jh_else_ce/diffs/components/diff_content.vue'; import { createAlert } from '~/flash'; diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 91c3df39e32..dff61acdfba 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -1,7 +1,6 @@ <script> import { GlTooltipDirective, - GlSafeHtmlDirective, GlIcon, GlBadge, GlButton, @@ -14,6 +13,7 @@ import { } from '@gitlab/ui'; import { escape } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { IdState } from 'vendor/vue-virtual-scroller'; import { scrollToElement } from '~/lib/utils/common_utils'; import { truncateSha } from '~/lib/utils/text_utility'; @@ -44,7 +44,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [IdState({ idProp: (vm) => vm.diffFile.file_hash }), glFeatureFlagsMixin()], i18n: { diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index 5ea118afe78..aa9a17d18e3 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -1,5 +1,4 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { mapGetters, mapState, mapActions } from 'vuex'; import { IdState } from 'vendor/vue-virtual-scroller'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -22,9 +21,6 @@ export default { DiffCommentCell, DraftNote, }, - directives: { - SafeHtml, - }, mixins: [ draftCommentsMixin, IdState({ idProp: (vm) => vm.diffFile.file_hash }), @@ -307,7 +303,11 @@ export default { class="diff-td notes-content parallel old" > <div v-for="draft in lineDrafts(line, 'left')" :key="draft.id" class="content"> - <draft-note :draft="draft" :line="line.left" /> + <article class="note-wrapper"> + <ul class="notes draft-notes"> + <draft-note :draft="draft" :line="line.left" /> + </ul> + </article> </div> </div> <div @@ -315,7 +315,11 @@ export default { class="diff-td notes-content parallel new" > <div v-for="draft in lineDrafts(line, 'right')" :key="draft.id" class="content"> - <draft-note :draft="draft" :line="line.right" /> + <article class="note-wrapper"> + <ul class="notes draft-notes"> + <draft-note :draft="draft" :line="line.right" /> + </ul> + </article> </div> </div> </div> diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js index f7f4aad3ad0..0f44eb06cb3 100644 --- a/app/assets/javascripts/diffs/i18n.js +++ b/app/assets/javascripts/diffs/i18n.js @@ -19,6 +19,7 @@ export const DIFF_FILE = { autoCollapsed: __('Files with large changes are collapsed by default.'), expand: __('Expand file'), }; +export const START_THREAD = __('Start another thread'); export const SETTINGS_DROPDOWN = { whitespace: __('Show whitespace changes'), @@ -49,3 +50,5 @@ export const CONFLICT_TEXT = { }; export const HIDE_COMMENTS = __('Hide comments'); + +export const NEW_CODE_QUALITY_FINDINGS = __('New code quality findings'); diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index b4ff5e4f250..7da5ef54b80 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import { mapActions, mapState, mapGetters } from 'vuex'; import { getCookie, parseBoolean, removeCookie } from '~/lib/utils/common_utils'; +import notesStore from '~/mr_notes/stores'; import eventHub from '../notes/event_hub'; import DiffsApp from './components/app.vue'; @@ -9,7 +10,7 @@ import { TREE_LIST_STORAGE_KEY, DIFF_WHITESPACE_COOKIE_NAME } from './constants' import { getReviewsForMergeRequest } from './utils/file_reviews'; import { getDerivedMergeRequestInformation } from './utils/merge_request'; -export default function initDiffsApp(store) { +export default function initDiffsApp(store = notesStore) { const vm = new Vue({ el: '#js-diffs-app', name: 'MergeRequestDiffs', diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index c73012527a2..96a73917820 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -52,7 +52,7 @@ import { isCollapsed } from '../utils/diff_file'; import { markFileReview, setReviewsForMergeRequest } from '../utils/file_reviews'; import { getDerivedMergeRequestInformation } from '../utils/merge_request'; import { queueRedisHllEvents } from '../utils/queue_events'; -import TreeWorker from '../workers/tree_worker'; +import TreeWorker from '../workers/tree_worker?worker'; import * as types from './mutation_types'; import { getDiffPositionByLineCode, @@ -444,20 +444,27 @@ export const scrollToLineIfNeededParallel = (_, line) => { } }; -export const loadCollapsedDiff = ({ commit, getters, state }, file) => - axios - .get(file.load_collapsed_diff_url, { - params: { - commit_id: getters.commitId, - w: state.showWhitespace ? '0' : '1', - }, - }) - .then((res) => { - commit(types.ADD_COLLAPSED_DIFFS, { - file, - data: res.data, - }); +export const loadCollapsedDiff = ({ commit, getters, state }, file) => { + const versionPath = state.mergeRequestDiff?.version_path; + const loadParams = { + commit_id: getters.commitId, + w: state.showWhitespace ? '0' : '1', + }; + + if (versionPath) { + const { diffId, startSha } = getDerivedMergeRequestInformation({ endpoint: versionPath }); + + loadParams.diff_id = diffId; + loadParams.start_sha = startSha; + } + + return axios.get(file.load_collapsed_diff_url, { params: loadParams }).then((res) => { + commit(types.ADD_COLLAPSED_DIFFS, { + file, + data: res.data, }); + }); +}; /** * Toggles the file discussions after user clicked on the toggle discussions button. diff --git a/app/assets/javascripts/diffs/utils/merge_request.js b/app/assets/javascripts/diffs/utils/merge_request.js index edb4304f558..43e04a814c5 100644 --- a/app/assets/javascripts/diffs/utils/merge_request.js +++ b/app/assets/javascripts/diffs/utils/merge_request.js @@ -1,14 +1,30 @@ const endpointRE = /^(\/?(.+?)\/(.+?)\/-\/merge_requests\/(\d+)).*$/i; +function getVersionInfo({ endpoint } = {}) { + const dummyRoot = 'https://gitlab.com'; + const endpointUrl = new URL(endpoint, dummyRoot); + const params = Object.fromEntries(endpointUrl.searchParams.entries()); + + const { start_sha: startSha, diff_id: diffId } = params; + + return { + diffId, + startSha, + }; +} + export function getDerivedMergeRequestInformation({ endpoint } = {}) { let mrPath; let userOrGroup; let project; let id; + let diffId; + let startSha; const matches = endpointRE.exec(endpoint); if (matches) { [, mrPath, userOrGroup, project, id] = matches; + ({ diffId, startSha } = getVersionInfo({ endpoint })); } return { @@ -16,5 +32,7 @@ export function getDerivedMergeRequestInformation({ endpoint } = {}) { userOrGroup, project, id, + diffId, + startSha, }; } diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar.vue b/app/assets/javascripts/editor/components/source_editor_toolbar.vue index 2c177634bbe..c72145f9d2f 100644 --- a/app/assets/javascripts/editor/components/source_editor_toolbar.vue +++ b/app/assets/javascripts/editor/components/source_editor_toolbar.vue @@ -57,13 +57,12 @@ export default { > <div v-for="group in $options.groups" :key="group"> <gl-button-group v-if="hasGroupItems(group)"> - <template v-for="item in getGroupItems(group)"> - <source-editor-toolbar-button - :key="item.id" - :button="item" - @click="$emit('click', item)" - /> - </template> + <source-editor-toolbar-button + v-for="item in getGroupItems(group)" + :key="item.id" + :button="item" + @click="$emit('click', item)" + /> </gl-button-group> </div> </section> diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue b/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue index 6ce48ddf89a..38f586f0773 100644 --- a/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue +++ b/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue @@ -31,12 +31,19 @@ export default { return Object.entries(this.button).length > 0; }, }, + mounted() { + if (this.button.data) { + Object.entries(this.button.data).forEach(([attr, value]) => { + this.$el.dataset[attr] = value; + }); + } + }, methods: { - clickHandler() { + clickHandler(event) { if (this.button.onClick) { - this.button.onClick(); + this.button.onClick(event); } - this.$emit('click'); + this.$emit('click', event); }, }, }; @@ -52,7 +59,7 @@ export default { :icon="icon" :title="label" :aria-label="label" - data-qa-selector="editor_toolbar_button" - @click="clickHandler" + :class="button.class" + @click="clickHandler($event)" /> </template> diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js index 83cfdd25757..d0649ecccba 100644 --- a/app/assets/javascripts/editor/constants.js +++ b/app/assets/javascripts/editor/constants.js @@ -1,5 +1,6 @@ +import { MODIFIER_KEY } from '~/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { s__, __ } from '~/locale'; +import { s__, __, sprintf } from '~/locale'; export const URI_PREFIX = 'gitlab'; export const CONTENT_UPDATE_DEBOUNCE = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; @@ -62,3 +63,104 @@ export const EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH = 0.5; // 50% of the width export const EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY = 250; // ms export const EXTENSION_MARKDOWN_PREVIEW_LABEL = __('Preview Markdown'); export const EXTENSION_MARKDOWN_HIDE_PREVIEW_LABEL = __('Hide Live Preview'); +export const EXTENSION_MARKDOWN_BUTTONS = [ + { + id: 'bold', + label: sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { + modifierKey: MODIFIER_KEY, + }), + data: { + mdTag: '**', + mdShortcuts: '["mod+b"]', + }, + }, + { + id: 'italic', + label: sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { + modifierKey: MODIFIER_KEY, + }), + data: { + mdTag: '_', + mdShortcuts: '["mod+i"]', + }, + }, + { + id: 'strikethrough', + label: sprintf(s__('MarkdownEditor|Add strikethrough text (%{modifierKey}⇧X)'), { + modifierKey: MODIFIER_KEY, + }), + data: { + mdTag: '~~', + mdShortcuts: '["mod+shift+x]', + }, + }, + { + id: 'quote', + label: __('Insert a quote'), + data: { + mdTag: '> ', + mdPrepend: true, + }, + }, + { + id: 'code', + label: __('Insert code'), + data: { + mdTag: '`', + mdBlock: '```', + }, + }, + { + id: 'link', + label: sprintf(s__('MarkdownEditor|Add a link (%{modifier_key}K)'), { + modifierKey: MODIFIER_KEY, + }), + data: { + mdTag: '[{text}](url)', + mdSelect: 'url', + mdShortcuts: '["mod+k"]', + }, + }, + { + id: 'list-bulleted', + label: __('Add a bullet list'), + data: { + mdTag: '- ', + mdPrepend: true, + }, + }, + { + id: 'list-numbered', + label: __('Add a numbered list'), + data: { + mdTag: '1. ', + mdPrepend: true, + }, + }, + { + id: 'list-task', + label: __('Add a checklist'), + data: { + mdTag: '- [ ] ', + mdPrepend: true, + }, + }, + { + id: 'details-block', + label: __('Add a collapsible section'), + data: { + mdTag: '<details><summary>Click to expand</summary>\n{text}\n</details>', + mdPrepend: true, + mdSelect: __('Click to expand'), + }, + }, + { + id: 'table', + label: __('Add a table'), + data: { + /* eslint-disable-next-line @gitlab/require-i18n-strings */ + mdTag: '| header | header |\n| ------ | ------ |\n| | |\n| | |', + mdPrepend: true, + }, + }, +]; diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js index a16fe93026e..6105a577996 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js @@ -1,8 +1,37 @@ +import { insertMarkdownText } from '~/lib/utils/text_markdown'; +import { EDITOR_TOOLBAR_RIGHT_GROUP, EXTENSION_MARKDOWN_BUTTONS } from '../constants'; + export class EditorMarkdownExtension { static get extensionName() { return 'EditorMarkdown'; } + onSetup(instance) { + this.toolbarButtons = []; + if (instance.toolbar) { + this.setupToolbar(instance); + } + } + onBeforeUnuse(instance) { + const ids = this.toolbarButtons.map((item) => item.id); + if (instance.toolbar) { + instance.toolbar.removeItems(ids); + } + } + + setupToolbar(instance) { + this.toolbarButtons = EXTENSION_MARKDOWN_BUTTONS.map((btn) => { + return { + ...btn, + icon: btn.id, + group: EDITOR_TOOLBAR_RIGHT_GROUP, + category: 'tertiary', + onClick: (e) => instance.insertMarkdown(e), + }; + }); + instance.toolbar.addItems(this.toolbarButtons); + } + // eslint-disable-next-line class-methods-use-this provides() { return { @@ -36,6 +65,25 @@ export class EditorMarkdownExtension { pos.lineNumber += dy; instance.setPosition(pos); }, + insertMarkdown: (instance, e) => { + const { + mdTag: tag, + mdBlock: blockTag, + mdPrepend, + mdSelect: select, + } = e.currentTarget.dataset; + + insertMarkdownText({ + tag, + blockTag, + wrap: !mdPrepend, + select, + selected: instance.getSelectedText(), + text: instance.getValue(), + editor: instance, + }); + instance.focus(); + }, /** * Adjust existing selection to select text within the original selection. * - If `selectedText` is not supplied, we fetch selected text with diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js index dd4a7a689d7..58ddaa94d5e 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js @@ -120,6 +120,9 @@ export class EditorMarkdownPreviewExtension { category: 'primary', selectedLabel: EXTENSION_MARKDOWN_HIDE_PREVIEW_LABEL, onClick: () => instance.togglePreview(), + data: { + qaSelector: 'editor_toolbar_button', + }, }, ]; instance.toolbar.addItems(this.toolbarButtons); diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index 45f063a2048..d94aa73e43a 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -41,6 +41,9 @@ "before_script": { "$ref": "#/definitions/before_script" }, + "hooks": { + "$ref": "#/definitions/hooks" + }, "cache": { "$ref": "#/definitions/cache" }, @@ -202,25 +205,11 @@ "when": { "markdownDescription": "Configure when artifacts are uploaded depended on job status. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#artifactswhen).", "default": "on_success", - "oneOf": [ - { - "enum": [ - "on_success" - ], - "description": "Upload artifacts only when the job succeeds (this is the default)." - }, - { - "enum": [ - "on_failure" - ], - "description": "Upload artifacts only when the job fails." - }, - { - "enum": [ - "always" - ], - "description": "Upload artifacts regardless of job status." - } + "type": "string", + "enum": [ + "on_success", + "on_failure", + "always" ] }, "expire_in": { @@ -347,10 +336,10 @@ "include_item": { "oneOf": [ { - "description": "Will infer the method based on the value. E.g. `https://...` strings will be of type `include:remote`, and `/templates/...` will be of type `include:local`.", + "description": "Will infer the method based on the value. E.g. `https://...` strings will be of type `include:remote`, and `/templates/...` or `templates/...` will be of type `include:local`.", "type": "string", "format": "uri-reference", - "pattern": "^(https?://|/).+\\.ya?ml$" + "pattern": "^(https?://|/?.?-?(?!\\w+://)\\w).+\\.ya?ml$" }, { "type": "object", @@ -585,56 +574,98 @@ ] } }, + "id_tokens": { + "type": "object", + "markdownDescription": "Defines JWTs to be injected as environment variables.", + "patternProperties": { + ".*": { + "type": "object", + "properties": { + "aud": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + } + ] + } + }, + "required": [ + "aud" + ], + "additionalProperties": false + } + } + }, "secrets": { "type": "object", "markdownDescription": "Defines secrets to be injected as environment variables. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#secrets).", - "additionalProperties": { - "type": "object", - "description": "Environment variable name", - "properties": { - "vault": { - "oneOf": [ - { - "type": "string", - "description": "The secret to be fetched from Vault (e.g. 'production/db/password@ops' translates to secret 'ops/data/production/db', field `password`)" - }, - { - "type": "object", - "properties": { - "engine": { - "type": "object", - "properties": { - "name": { - "type": "string" + "patternProperties": { + ".*": { + "type": "object", + "properties": { + "vault": { + "oneOf": [ + { + "type": "string", + "markdownDescription": "The secret to be fetched from Vault (e.g. 'production/db/password@ops' translates to secret 'ops/data/production/db', field `password`). [Learn More](https://docs.gitlab.com/ee/ci/yaml/#secretsvault)" + }, + { + "type": "object", + "properties": { + "engine": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + } }, - "path": { - "type": "string" - } + "required": [ + "name", + "path" + ] }, - "required": [ - "name", - "path" - ] - }, - "path": { - "type": "string" + "path": { + "type": "string" + }, + "field": { + "type": "string" + } }, - "field": { - "type": "string" - } - }, - "required": [ - "engine", - "path", - "field" - ] - } - ] - } - }, - "required": [ - "vault" - ] + "required": [ + "engine", + "path", + "field" + ], + "additionalProperties": false + } + ] + }, + "file": { + "type": "boolean", + "default": true, + "markdownDescription": "Configures the secret to be stored as either a file or variable type CI/CD variable. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#secretsfile)" + }, + "token": { + "type": "string", + "description": "Specifies the JWT variable that should be used to authenticate with Hashicorp Vault." + } + }, + "required": [ + "vault" + ], + "additionalProperties": false + } } }, "before_script": { @@ -739,7 +770,17 @@ "type": "object", "properties": { "value": { - "type": "string" + "type": "string", + "markdownDescription": "Default value of the variable. If used with `options`, `value` must be included in the array. [Learn More](https://docs.gitlab.com/ee/ci/pipelines/index.html#prefill-variables-in-manual-pipelines)" + }, + "options": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true, + "markdownDescription": "A list of predefined values that users can select from in the **Run pipeline** page when running a pipeline manually. [Learn More](https://docs.gitlab.com/ee/ci/pipelines/index.html#configure-a-list-of-selectable-values-for-a-prefilled-variable)" }, "description": { "type": "string", @@ -959,6 +1000,7 @@ "default": false }, "when": { + "type": "string", "markdownDescription": "Defines when to save the cache, based on the status of the job. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#cachewhen).", "default": "on_success", "enum": [ @@ -1200,6 +1242,9 @@ "after_script": { "$ref": "#/definitions/after_script" }, + "hooks": { + "$ref": "#/definitions/hooks" + }, "rules": { "$ref": "#/definitions/rules" }, @@ -1209,6 +1254,9 @@ "cache": { "$ref": "#/definitions/cache" }, + "id_tokens": { + "$ref": "#/definitions/id_tokens" + }, "secrets": { "$ref": "#/definitions/secrets" }, @@ -1861,6 +1909,39 @@ } ] } + }, + "hooks": { + "type": "object", + "markdownDescription": "Specifies lists of commands to execute on the runner at certain stages of job execution. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#hooks).", + "properties": { + "pre_get_sources_script": { + "markdownDescription": "Specifies a list of commands to execute on the runner before updating the Git repository and any submodules. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#hookspre_get_sources_script).", + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "minItems": 1 + } + ] + } + }, + "additionalProperties": false } } }
\ No newline at end of file diff --git a/app/assets/javascripts/environments/components/deploy_board.vue b/app/assets/javascripts/environments/components/deploy_board.vue index f22a0705b3d..31bc462f0b9 100644 --- a/app/assets/javascripts/environments/components/deploy_board.vue +++ b/app/assets/javascripts/environments/components/deploy_board.vue @@ -15,10 +15,10 @@ import { GlLink, GlTooltip, GlTooltipDirective, - GlSafeHtmlDirective as SafeHtml, GlSprintf, } from '@gitlab/ui'; import { isEmpty } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__, n__ } from '~/locale'; import InstanceComponent from '~/vue_shared/components/deployment_instance.vue'; import { STATUS_MAP, CANARY_STATUS } from '../constants'; diff --git a/app/assets/javascripts/environments/environment_details/constants.js b/app/assets/javascripts/environments/environment_details/constants.js new file mode 100644 index 00000000000..56c70c354b7 --- /dev/null +++ b/app/assets/javascripts/environments/environment_details/constants.js @@ -0,0 +1,47 @@ +import { __ } from '~/locale'; + +export const ENVIRONMENT_DETAILS_PAGE_SIZE = 20; +export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [ + { + key: 'status', + label: __('Status'), + columnClass: 'gl-w-10p', + tdClass: 'gl-vertical-align-middle!', + }, + { + key: 'id', + label: __('ID'), + columnClass: 'gl-w-5p', + tdClass: 'gl-vertical-align-middle!', + }, + { + key: 'triggerer', + label: __('Triggerer'), + columnClass: 'gl-w-10p', + tdClass: 'gl-vertical-align-middle!', + }, + { + key: 'commit', + label: __('Commit'), + columnClass: 'gl-w-20p', + tdClass: 'gl-vertical-align-middle!', + }, + { + key: 'job', + label: __('Job'), + columnClass: 'gl-w-20p', + tdClass: 'gl-vertical-align-middle!', + }, + { + key: 'created', + label: __('Created'), + columnClass: 'gl-w-10p', + tdClass: 'gl-vertical-align-middle! gl-white-space-nowrap', + }, + { + key: 'deployed', + label: __('Deployed'), + columnClass: 'gl-w-10p', + tdClass: 'gl-vertical-align-middle! gl-white-space-nowrap', + }, +]; diff --git a/app/assets/javascripts/environments/environment_details/index.vue b/app/assets/javascripts/environments/environment_details/index.vue new file mode 100644 index 00000000000..435d3fd820e --- /dev/null +++ b/app/assets/javascripts/environments/environment_details/index.vue @@ -0,0 +1,118 @@ +<script> +import { + GlTableLite, + GlAvatarLink, + GlAvatar, + GlLink, + GlTooltipDirective, + GlTruncate, + GlBadge, + GlLoadingIcon, +} from '@gitlab/ui'; +import Commit from '~/vue_shared/components/commit.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import environmentDetailsQuery from '../graphql/queries/environment_details.query.graphql'; +import { convertToDeploymentTableRow } from '../helpers/deployment_data_transformation_helper'; +import DeploymentStatusBadge from '../components/deployment_status_badge.vue'; +import { ENVIRONMENT_DETAILS_PAGE_SIZE, ENVIRONMENT_DETAILS_TABLE_FIELDS } from './constants'; + +export default { + components: { + GlLoadingIcon, + GlBadge, + DeploymentStatusBadge, + TimeAgoTooltip, + GlTableLite, + GlAvatarLink, + GlAvatar, + GlLink, + GlTruncate, + Commit, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + projectFullPath: { + type: String, + required: true, + }, + environmentName: { + type: String, + required: true, + }, + }, + apollo: { + project: { + query: environmentDetailsQuery, + variables() { + return { + projectFullPath: this.projectFullPath, + environmentName: this.environmentName, + pageSize: ENVIRONMENT_DETAILS_PAGE_SIZE, + }; + }, + }, + }, + data() { + return { + project: { + loading: true, + }, + loading: 0, + tableFields: ENVIRONMENT_DETAILS_TABLE_FIELDS, + }; + }, + computed: { + deployments() { + return this.project.environment?.deployments.nodes.map(convertToDeploymentTableRow) || []; + }, + isLoading() { + return this.$apollo.queries.project.loading; + }, + }, +}; +</script> +<template> + <div> + <gl-loading-icon v-if="isLoading" size="lg" class="mt-3" /> + <gl-table-lite v-else :items="deployments" :fields="tableFields" fixed stacked="lg"> + <template #table-colgroup="{ fields }"> + <col v-for="field in fields" :key="field.key" :class="field.columnClass" /> + </template> + <template #cell(status)="{ item }"> + <div> + <deployment-status-badge :status="item.status" /> + </div> + </template> + <template #cell(id)="{ item }"> + <strong>{{ item.id }}</strong> + </template> + <template #cell(triggerer)="{ item }"> + <gl-avatar-link :href="item.triggerer.webUrl"> + <gl-avatar + v-gl-tooltip + :title="item.triggerer.name" + :src="item.triggerer.avatarUrl" + :size="24" + /> + </gl-avatar-link> + </template> + <template #cell(commit)="{ item }"> + <commit v-bind="item.commit" /> + </template> + <template #cell(job)="{ item }"> + <gl-link v-if="item.job" :href="item.job.webPath"> + <gl-truncate :text="item.job.label" /> + </gl-link> + <gl-badge v-else variant="info">{{ __('API') }}</gl-badge> + </template> + <template #cell(created)="{ item }"> + <time-ago-tooltip :time="item.created" /> + </template> + <template #cell(deployed)="{ item }"> + <time-ago-tooltip :time="item.deployed" /> + </template> + </gl-table-lite> + </div> +</template> diff --git a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql new file mode 100644 index 00000000000..e8f2a2cdf7f --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql @@ -0,0 +1,48 @@ +query getEnvironmentDetails($projectFullPath: ID!, $environmentName: String, $pageSize: Int) { + project(fullPath: $projectFullPath) { + id + name + fullPath + environment(name: $environmentName) { + id + name + deployments(orderBy: { createdAt: DESC }, first: $pageSize) { + nodes { + id + iid + status + ref + tag + job { + name + id + webPath + } + commit { + id + shortId + message + webUrl + authorGravatar + authorName + authorEmail + author { + id + name + avatarUrl + webUrl + } + } + triggerer { + id + webUrl + name + avatarUrl + } + createdAt + finishedAt + } + } + } + } +} diff --git a/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js b/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js new file mode 100644 index 00000000000..bfe92fe3125 --- /dev/null +++ b/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js @@ -0,0 +1,62 @@ +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + +/** + * This function transforms Commit object coming from GraphQL to object compatible with app/assets/javascripts/vue_shared/components/commit.vue author object + * @param {Object} Commit + * @returns {Object} + */ +export const getAuthorFromCommit = (commit) => { + if (commit.author) { + return { + username: commit.author.name, + path: commit.author.webUrl, + avatar_url: commit.author.avatarUrl, + }; + } + return { + username: commit.authorName, + path: `mailto:${commit.authorEmail}`, + avatar_url: commit.authorGravatar, + }; +}; + +/** + * This function transforms deploymentNode object coming from GraphQL to object compatible with app/assets/javascripts/vue_shared/components/commit.vue + * @param {Object} deploymentNode + * @returns {Object} + */ +export const getCommitFromDeploymentNode = (deploymentNode) => { + if (!deploymentNode.commit) { + throw new Error("deploymentNode argument doesn't have 'commit' field", deploymentNode); + } + return { + title: deploymentNode.commit.message, + commitUrl: deploymentNode.commit.webUrl, + shortSha: deploymentNode.commit.shortId, + tag: deploymentNode.tag, + commitRef: { + name: deploymentNode.ref, + }, + author: getAuthorFromCommit(deploymentNode.commit), + }; +}; + +/** + * This function transforms deploymentNode object coming from GraphQL to object compatible with app/assets/javascripts/environments/environment_details/page.vue table + * @param {Object} deploymentNode + * @returns {Object} + */ +export const convertToDeploymentTableRow = (deploymentNode) => { + return { + status: deploymentNode.status.toLowerCase(), + id: deploymentNode.iid, + triggerer: deploymentNode.triggerer, + commit: getCommitFromDeploymentNode(deploymentNode), + job: deploymentNode.job && { + webPath: deploymentNode.job.webPath, + label: `${deploymentNode.job.name} (#${getIdFromGraphQLId(deploymentNode.job.id)})`, + }, + created: deploymentNode.createdAt || '', + deployed: deploymentNode.finishedAt || '', + }; +}; diff --git a/app/assets/javascripts/environments/mount_show.js b/app/assets/javascripts/environments/mount_show.js index 6df4fad83f2..ba816599ac2 100644 --- a/app/assets/javascripts/environments/mount_show.js +++ b/app/assets/javascripts/environments/mount_show.js @@ -1,6 +1,8 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import EnvironmentsDetailHeader from './components/environments_detail_header.vue'; +import { apolloProvider } from './graphql/client'; import environmentsMixin from './mixins/environments_mixin'; export const initHeader = () => { @@ -41,7 +43,33 @@ export const initHeader = () => { cancelAutoStopPath: dataset.environmentCancelAutoStopPath, terminalPath: dataset.environmentTerminalPath, metricsPath: dataset.environmentMetricsPath, - updatePath: dataset.environmentEditPath, + updatePath: dataset.tnvironmentEditPath, + }, + }); + }, + }); +}; + +export const initPage = async () => { + if (!gon.features.environmentDetailsVue) { + return null; + } + const EnvironmentsDetailPageModule = await import('./environment_details/index.vue'); + const EnvironmentsDetailPage = EnvironmentsDetailPageModule.default; + const dataElement = document.getElementById('environments-detail-view'); + const dataSet = convertObjectPropsToCamelCase(JSON.parse(dataElement.dataset.details)); + + Vue.use(VueApollo); + const el = document.getElementById('environment_details_page'); + return new Vue({ + el, + apolloProvider: apolloProvider(), + provide: {}, + render(createElement) { + return createElement(EnvironmentsDetailPage, { + props: { + projectFullPath: dataSet.projectFullPath, + environmentName: dataSet.name, }, }); }, diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index de4b11699fc..122c7c005e9 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -357,7 +357,7 @@ export default { > <span class="d-flex"> <gl-icon - class="gl-new-dropdown-item-check-icon" + class="gl-dropdown-item-check-icon" :class="{ invisible: !isCurrentStatusFilter(status) }" name="mobile-issue-close" /> @@ -374,7 +374,7 @@ export default { > <span class="d-flex"> <gl-icon - class="gl-new-dropdown-item-check-icon" + class="gl-dropdown-item-check-icon" :class="{ invisible: !isCurrentSortField(field) }" name="mobile-issue-close" /> diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue index 34d01f21da2..6ddd982ebf1 100644 --- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue +++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue @@ -1,5 +1,6 @@ <script> -import { GlTooltip, GlSprintf, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlTooltip, GlSprintf, GlIcon } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue index f0f42d19ea5..286b214b511 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue @@ -4,6 +4,8 @@ import { __, s__, sprintf } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { labelForStrategy } from '../utils'; +import StrategyLabel from './strategy_label.vue'; + export default { i18n: { deleteLabel: __('Delete'), @@ -15,6 +17,7 @@ export default { GlButton, GlModal, GlToggle, + StrategyLabel, }, directives: { GlTooltip: GlTooltipDirective, @@ -166,14 +169,13 @@ export default { <div class="table-mobile-content d-flex flex-wrap justify-content-end justify-content-md-start js-feature-flag-environments" > - <gl-badge + <strategy-label v-for="strategy in featureFlag.strategies" :key="strategy.id" - data-testid="strategy-badge" - variant="info" - class="gl-mr-3 gl-mt-2 gl-white-space-normal gl-text-left gl-px-5" - >{{ strategyBadgeText(strategy) }}</gl-badge - > + data-testid="strategy-label" + class="w-100 gl-mr-3 gl-mt-2 gl-white-space-normal gl-text-left" + v-bind="strategyBadgeText(strategy)" + /> </div> </div> diff --git a/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue index 1a470d74b59..0fde87dd0ba 100644 --- a/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue +++ b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue @@ -90,10 +90,10 @@ export default { :id="inputId" :value="percentage" :state="isValid" - class="rollout-percentage gl-text-right gl-w-9" type="number" min="0" max="100" + size="xs" @input="onPercentageChange" /> <span class="ml-1">%</span> diff --git a/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue b/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue index 91e1b85d66e..0acb0d4366c 100644 --- a/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue +++ b/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue @@ -56,10 +56,10 @@ export default { :id="inputId" :value="percentage" :state="isValid" - class="rollout-percentage gl-text-right gl-w-9" type="number" min="0" max="100" + size="xs" @input="onPercentageChange" /> <span class="gl-ml-2">%</span> diff --git a/app/assets/javascripts/feature_flags/components/strategy_label.vue b/app/assets/javascripts/feature_flags/components/strategy_label.vue new file mode 100644 index 00000000000..c2d3ec5708f --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/strategy_label.vue @@ -0,0 +1,29 @@ +<script> +export default { + props: { + name: { + type: String, + required: true, + }, + scopes: { + type: String, + required: false, + default: null, + }, + parameters: { + type: String, + required: false, + default: null, + }, + }, +}; +</script> +<template> + <div> + <strong class="gl-fw-bold" + >{{ name }}<span v-if="parameters"> - {{ parameters }}</span + >:</strong + > + <span v-if="scopes">{{ scopes }}</span> + </div> +</template> diff --git a/app/assets/javascripts/feature_flags/utils.js b/app/assets/javascripts/feature_flags/utils.js index e77cb8406cc..47deeab0571 100644 --- a/app/assets/javascripts/feature_flags/utils.js +++ b/app/assets/javascripts/feature_flags/utils.js @@ -50,17 +50,11 @@ const scopeName = ({ environment_scope: scope }) => export const labelForStrategy = (strategy) => { const { name, parameters } = badgeTextByType[strategy.name]; + const scopes = strategy.scopes.map(scopeName).join(', '); - if (parameters) { - return sprintf('%{name} - %{parameters}: %{scopes}', { - name, - parameters: parameters(strategy), - scopes: strategy.scopes.map(scopeName).join(', '), - }); - } - - return sprintf('%{name}: %{scopes}', { + return { name, - scopes: strategy.scopes.map(scopeName).join(', '), - }); + parameters: parameters ? parameters(strategy) : null, + scopes, + }; }; diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue b/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue index 79d7eb94569..1c6e6380e76 100644 --- a/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue +++ b/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue @@ -1,12 +1,7 @@ <script> import clusterPopover from '@gitlab/svgs/dist/illustrations/cluster_popover.svg'; -import { - GlPopover, - GlSprintf, - GlLink, - GlButton, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; +import { GlPopover, GlSprintf, GlLink, GlButton } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __ } from '~/locale'; import { POPOVER_TARGET_ID } from './constants'; import { dismiss } from './feature_highlight_helper'; 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 d9c627f5c93..397ba879866 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,9 +1,16 @@ -import { __, s__ } from '~/locale'; +import { __ } from '~/locale'; +import { + TOKEN_TITLE_APPROVED_BY, + TOKEN_TITLE_REVIEWER, + TOKEN_TYPE_APPROVED_BY, + TOKEN_TYPE_REVIEWER, + TOKEN_TYPE_TARGET_BRANCH, +} from '~/vue_shared/components/filtered_search_bar/constants'; export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { const reviewerToken = { - formattedKey: s__('SearchToken|Reviewer'), - key: 'reviewer', + formattedKey: TOKEN_TITLE_REVIEWER, + key: TOKEN_TYPE_REVIEWER, type: 'string', param: 'username', symbol: '@', @@ -53,7 +60,7 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { if (!disableTargetBranchFilter) { const targetBranchToken = { formattedKey: __('Target-Branch'), - key: 'target-branch', + key: TOKEN_TYPE_TARGET_BRANCH, type: 'string', param: '', symbol: '', @@ -67,8 +74,8 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { const approvedBy = { token: { - formattedKey: __('Approved-By'), - key: 'approved-by', + formattedKey: TOKEN_TITLE_APPROVED_BY, + key: TOKEN_TYPE_APPROVED_BY, type: 'array', param: 'usernames[]', symbol: '@', @@ -76,8 +83,8 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { tag: '@approved-by', }, tokenAlternative: { - formattedKey: __('Approved-By'), - key: 'approved-by', + formattedKey: TOKEN_TITLE_APPROVED_BY, + key: TOKEN_TYPE_APPROVED_BY, type: 'string', param: 'usernames', symbol: '@', @@ -85,25 +92,25 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { condition: [ { url: 'approved_by_usernames[]=None', - tokenKey: 'approved-by', + tokenKey: TOKEN_TYPE_APPROVED_BY, value: __('None'), operator: '=', }, { url: 'not[approved_by_usernames][]=None', - tokenKey: 'approved-by', + tokenKey: TOKEN_TYPE_APPROVED_BY, value: __('None'), operator: '!=', }, { url: 'approved_by_usernames[]=Any', - tokenKey: 'approved-by', + tokenKey: TOKEN_TYPE_APPROVED_BY, value: __('Any'), operator: '=', }, { url: 'not[approved_by_usernames][]=Any', - tokenKey: 'approved-by', + tokenKey: TOKEN_TYPE_APPROVED_BY, value: __('Any'), operator: '!=', }, diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js index 3913e4e8d81..1f8baa470d8 100644 --- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -1,5 +1,17 @@ import { sortMilestonesByDueDate } from '~/milestones/utils'; -import { mergeUrlParams } from '../lib/utils/url_utility'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import { + TOKEN_TYPE_APPROVED_BY, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_CONFIDENTIAL, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_RELEASE, + TOKEN_TYPE_REVIEWER, + TOKEN_TYPE_TARGET_BRANCH, +} from '~/vue_shared/components/filtered_search_bar/constants'; import DropdownEmoji from './dropdown_emoji'; import DropdownHint from './dropdown_hint'; import DropdownNonUser from './dropdown_non_user'; @@ -58,17 +70,17 @@ export default class AvailableDropdownMappings { getMappings() { return { - author: { + [TOKEN_TYPE_AUTHOR]: { reference: null, gl: DropdownUser, element: this.container.querySelector('#js-dropdown-author'), }, - assignee: { + [TOKEN_TYPE_ASSIGNEE]: { reference: null, gl: DropdownUser, element: this.container.querySelector('#js-dropdown-assignee'), }, - reviewer: { + [TOKEN_TYPE_REVIEWER]: { reference: null, gl: DropdownUser, element: this.container.querySelector('#js-dropdown-reviewer'), @@ -78,12 +90,12 @@ export default class AvailableDropdownMappings { gl: DropdownUser, element: this.container.getElementById('js-dropdown-attention-requested'), }, - 'approved-by': { + [TOKEN_TYPE_APPROVED_BY]: { reference: null, gl: DropdownUser, element: this.container.querySelector('#js-dropdown-approved-by'), }, - milestone: { + [TOKEN_TYPE_MILESTONE]: { reference: null, gl: DropdownNonUser, extraArguments: { @@ -93,7 +105,7 @@ export default class AvailableDropdownMappings { }, element: this.container.querySelector('#js-dropdown-milestone'), }, - release: { + [TOKEN_TYPE_RELEASE]: { reference: null, gl: DropdownNonUser, extraArguments: { @@ -106,7 +118,7 @@ export default class AvailableDropdownMappings { }, element: this.container.querySelector('#js-dropdown-release'), }, - label: { + [TOKEN_TYPE_LABEL]: { reference: null, gl: DropdownNonUser, extraArguments: { @@ -116,7 +128,7 @@ export default class AvailableDropdownMappings { }, element: this.container.querySelector('#js-dropdown-label'), }, - 'my-reaction': { + [TOKEN_TYPE_MY_REACTION]: { reference: null, gl: DropdownEmoji, element: this.container.querySelector('#js-dropdown-my-reaction'), @@ -126,12 +138,12 @@ export default class AvailableDropdownMappings { gl: DropdownNonUser, element: this.container.querySelector('#js-dropdown-wip'), }, - confidential: { + [TOKEN_TYPE_CONFIDENTIAL]: { reference: null, gl: DropdownNonUser, element: this.container.querySelector('#js-dropdown-confidential'), }, - 'target-branch': { + [TOKEN_TYPE_TARGET_BRANCH]: { reference: null, gl: DropdownNonUser, extraArguments: { diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js index e07dccd11e8..b328ae6a872 100644 --- a/app/assets/javascripts/filtered_search/constants.js +++ b/app/assets/javascripts/filtered_search/constants.js @@ -1,4 +1,17 @@ -export const USER_TOKEN_TYPES = ['author', 'assignee', 'approved-by', 'reviewer', 'attention']; +import { + TOKEN_TYPE_APPROVED_BY, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_REVIEWER, +} from '~/vue_shared/components/filtered_search_bar/constants'; + +export const USER_TOKEN_TYPES = [ + TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_APPROVED_BY, + TOKEN_TYPE_REVIEWER, + 'attention', +]; export const DROPDOWN_TYPE = { hint: 'hint', diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js index 22e1604871a..38909db0555 100644 --- a/app/assets/javascripts/filtered_search/dropdown_utils.js +++ b/app/assets/javascripts/filtered_search/dropdown_utils.js @@ -1,4 +1,5 @@ import { last } from 'lodash'; +import { TOKEN_TYPE_LABEL } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchContainer from './container'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; import FilteredSearchTokenizer from './filtered_search_tokenizer'; @@ -113,7 +114,7 @@ export default class DropdownUtils { visualToken && visualToken.querySelector('.value') && visualToken.querySelector('.value').textContent.trim(); - if (tokenName === 'label' && tokenValue) { + if (tokenName === TOKEN_TYPE_LABEL && tokenValue) { // remove leading symbol and wrapping quotes tokenValue = tokenValue.replace(/^~("|')?(.*)/, '$2').replace(/("|')$/, ''); } diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index bc0f5398b4c..16c70fdd069 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -10,8 +10,12 @@ import { DOWN_KEY_CODE, } from '~/lib/utils/keycodes'; import { __ } from '~/locale'; -import { addClassIfElementExists } from '../lib/utils/dom_utils'; -import { visitUrl, getUrlParamsArray, getParameterByName } from '../lib/utils/url_utility'; +import { addClassIfElementExists } from '~/lib/utils/dom_utils'; +import { visitUrl, getUrlParamsArray, getParameterByName } from '~/lib/utils/url_utility'; +import { + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, +} from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchContainer from './container'; import DropdownUtils from './dropdown_utils'; import eventHub from './event_hub'; @@ -675,7 +679,7 @@ export default class FilteredSearchManager { const id = parseInt(value, 10); if (usernameParams[id]) { hasFilteredSearch = true; - const tokenName = 'assignee'; + const tokenName = TOKEN_TYPE_ASSIGNEE; const canEdit = this.canEdit && this.canEdit(tokenName); const operator = FilteredSearchVisualTokens.getOperatorToken(usernameParams[id]); const valueToken = FilteredSearchVisualTokens.getValueToken(usernameParams[id]); @@ -688,7 +692,7 @@ export default class FilteredSearchManager { const id = parseInt(value, 10); if (usernameParams[id]) { hasFilteredSearch = true; - const tokenName = 'author'; + const tokenName = TOKEN_TYPE_AUTHOR; const canEdit = this.canEdit && this.canEdit(tokenName); const operator = FilteredSearchVisualTokens.getOperatorToken(usernameParams[id]); const valueToken = FilteredSearchVisualTokens.getValueToken(usernameParams[id]); diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index 0c01220a7be..4994559e923 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -1,5 +1,6 @@ import { spriteIcon } from '~/lib/utils/common_utils'; import { objectToQuery } from '~/lib/utils/url_utility'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchContainer from './container'; import VisualTokenValue from './visual_token_value'; @@ -38,7 +39,7 @@ export default class FilteredSearchVisualTokens { lastVisualToken, isLastVisualTokenValid: lastVisualToken === null || - lastVisualToken.className.indexOf('filtered-search-term') !== -1 || + lastVisualToken.className.indexOf(FILTERED_SEARCH_TERM) !== -1 || (lastVisualToken && lastVisualToken.querySelector('.operator') !== null && lastVisualToken.querySelector('.value') !== null), @@ -113,7 +114,7 @@ export default class FilteredSearchVisualTokens { } = options; const li = document.createElement('li'); li.classList.add('js-visual-token'); - li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token'); + li.classList.add(isSearchTerm ? FILTERED_SEARCH_TERM : 'filtered-search-token'); if (!isSearchTerm) { li.classList.add(tokenClass); @@ -239,7 +240,7 @@ export default class FilteredSearchVisualTokens { static addSearchVisualToken(searchTerm) { const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); - if (lastVisualToken && lastVisualToken.classList.contains('filtered-search-term')) { + if (lastVisualToken && lastVisualToken.classList.contains(FILTERED_SEARCH_TERM)) { lastVisualToken.querySelector('.name').textContent += ` ${searchTerm}`; } else { FilteredSearchVisualTokens.addVisualTokenElement({ 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 d6e7887f93f..8aa99ec52f9 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 @@ -7,13 +7,20 @@ import { TOKEN_TITLE_MILESTONE, TOKEN_TITLE_MY_REACTION, TOKEN_TITLE_RELEASE, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_RELEASE, + TOKEN_TYPE_REVIEWER, } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchTokenKeys from './filtered_search_token_keys'; export const tokenKeys = [ { formattedKey: TOKEN_TITLE_AUTHOR, - key: 'author', + key: TOKEN_TYPE_AUTHOR, type: 'string', param: 'username', symbol: '@', @@ -22,7 +29,7 @@ export const tokenKeys = [ }, { formattedKey: TOKEN_TITLE_ASSIGNEE, - key: 'assignee', + key: TOKEN_TYPE_ASSIGNEE, type: 'string', param: 'username', symbol: '@', @@ -31,7 +38,7 @@ export const tokenKeys = [ }, { formattedKey: TOKEN_TITLE_MILESTONE, - key: 'milestone', + key: TOKEN_TYPE_MILESTONE, type: 'string', param: 'title', symbol: '%', @@ -40,7 +47,7 @@ export const tokenKeys = [ }, { formattedKey: TOKEN_TITLE_RELEASE, - key: 'release', + key: TOKEN_TYPE_RELEASE, type: 'string', param: 'tag', symbol: '', @@ -49,7 +56,7 @@ export const tokenKeys = [ }, { formattedKey: TOKEN_TITLE_LABEL, - key: 'label', + key: TOKEN_TYPE_LABEL, type: 'array', param: 'name[]', symbol: '~', @@ -62,7 +69,7 @@ if (gon.current_user_id) { // Appending tokenkeys only logged-in tokenKeys.push({ formattedKey: TOKEN_TITLE_MY_REACTION, - key: 'my-reaction', + key: TOKEN_TYPE_MY_REACTION, type: 'string', param: 'emoji', symbol: '', @@ -74,7 +81,7 @@ if (gon.current_user_id) { export const alternativeTokenKeys = [ { formattedKey: TOKEN_TITLE_LABEL, - key: 'label', + key: TOKEN_TYPE_LABEL, type: 'string', param: 'name', symbol: '~', @@ -85,77 +92,77 @@ export const conditions = flattenDeep( [ { url: 'assignee_id=None', - tokenKey: 'assignee', + tokenKey: TOKEN_TYPE_ASSIGNEE, value: __('None'), }, { url: 'assignee_id=Any', - tokenKey: 'assignee', + tokenKey: TOKEN_TYPE_ASSIGNEE, value: __('Any'), }, { url: 'reviewer_id=None', - tokenKey: 'reviewer', + tokenKey: TOKEN_TYPE_REVIEWER, value: __('None'), }, { url: 'reviewer_id=Any', - tokenKey: 'reviewer', + tokenKey: TOKEN_TYPE_REVIEWER, value: __('Any'), }, { url: 'author_username=support-bot', - tokenKey: 'author', + tokenKey: TOKEN_TYPE_AUTHOR, value: 'support-bot', }, { url: 'milestone_title=None', - tokenKey: 'milestone', + tokenKey: TOKEN_TYPE_MILESTONE, value: __('None'), }, { url: 'milestone_title=Any', - tokenKey: 'milestone', + tokenKey: TOKEN_TYPE_MILESTONE, value: __('Any'), }, { url: 'milestone_title=%23upcoming', - tokenKey: 'milestone', + tokenKey: TOKEN_TYPE_MILESTONE, value: __('Upcoming'), }, { url: 'milestone_title=%23started', - tokenKey: 'milestone', + tokenKey: TOKEN_TYPE_MILESTONE, value: __('Started'), }, { url: 'release_tag=None', - tokenKey: 'release', + tokenKey: TOKEN_TYPE_RELEASE, value: __('None'), }, { url: 'release_tag=Any', - tokenKey: 'release', + tokenKey: TOKEN_TYPE_RELEASE, value: __('Any'), }, { url: 'label_name[]=None', - tokenKey: 'label', + tokenKey: TOKEN_TYPE_LABEL, value: __('None'), }, { url: 'label_name[]=Any', - tokenKey: 'label', + tokenKey: TOKEN_TYPE_LABEL, value: __('Any'), }, { url: 'my_reaction_emoji=None', - tokenKey: 'my-reaction', + tokenKey: TOKEN_TYPE_MY_REACTION, value: __('None'), }, { url: 'my_reaction_emoji=Any', - tokenKey: 'my-reaction', + tokenKey: TOKEN_TYPE_MY_REACTION, value: __('Any'), }, ].map((condition) => { diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js index 1ad2006d689..33fda7533e4 100644 --- a/app/assets/javascripts/filtered_search/visual_token_value.js +++ b/app/assets/javascripts/filtered_search/visual_token_value.js @@ -8,6 +8,7 @@ import { createAlert } from '~/flash'; import AjaxCache from '~/lib/utils/ajax_cache'; import UsersCache from '~/lib/utils/users_cache'; import { __ } from '~/locale'; +import { TOKEN_TYPE_LABEL } from '~/vue_shared/components/filtered_search_bar/constants'; export default class VisualTokenValue { constructor(tokenValue, tokenType, tokenOperator) { @@ -23,7 +24,7 @@ export default class VisualTokenValue { return; } - if (tokenType === 'label') { + if (tokenType === TOKEN_TYPE_LABEL) { this.updateLabelTokenColor(tokenValueContainer); } else if (USER_TOKEN_TYPES.includes(tokenType)) { this.updateUserTokenAppearance(tokenValueContainer, tokenValueElement); diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index dc6c4642e94..9e804b60d59 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -114,6 +114,7 @@ const addDismissFlashClickListener = (flashEl, fadeTransition) => { * @param {object} [options.parent] - Reference to parent element under which alert needs to appear. Defaults to `document`. * @param {Function} [options.onDismiss] - Handler to call when this alert is dismissed. * @param {string} [options.containerSelector] - Selector for the container of the alert + * @param {boolean} [options.preservePrevious] - Set to `true` to preserve previous alerts. Defaults to `false`. * @param {object} [options.primaryButton] - Object describing primary button of alert * @param {string} [options.primaryButton.link] - Href of primary button * @param {string} [options.primaryButton.text] - Text of primary button @@ -131,6 +132,7 @@ const createAlert = function createAlert({ variant = VARIANT_DANGER, parent = document, containerSelector = '.flash-container', + preservePrevious = false, primaryButton = null, secondaryButton = null, onDismiss = null, @@ -143,7 +145,11 @@ const createAlert = function createAlert({ if (!alertContainer) return null; const el = document.createElement('div'); - alertContainer.appendChild(el); + if (preservePrevious) { + alertContainer.appendChild(el); + } else { + alertContainer.replaceChildren(el); + } return new Vue({ el, 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 33ab1d5cd7f..89b6885091c 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,6 +1,7 @@ <script> -import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { snakeCase } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import highlight from '~/lib/utils/highlight'; import { truncateNamespace } from '~/lib/utils/text_utility'; import { mapVuexModuleState } from '~/lib/utils/vuex_module_mappers'; @@ -15,7 +16,7 @@ export default { ProjectAvatar, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [trackingMixin], inject: ['vuexModule'], diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 49c47e9d778..293cd2df16f 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -538,7 +538,12 @@ class GfmAutoComplete { setupLabels($input) { const instance = this; const fetchData = this.fetchData.bind(this); - const LABEL_COMMAND = { LABEL: '/label', UNLABEL: '/unlabel', RELABEL: '/relabel' }; + const LABEL_COMMAND = { + LABEL: '/label', + LABELS: '/labels', + UNLABEL: '/unlabel', + RELABEL: '/relabel', + }; let command = ''; $input.atwho({ @@ -570,13 +575,9 @@ class GfmAutoComplete { matcher(flag, subtext) { const subtextNodes = subtext.split(/\n+/g).pop().split(GfmAutoComplete.regexSubtext); - // Check if ~ is followed by '/label', '/relabel' or '/unlabel' commands. + // Check if ~ is followed by '/label', '/labels', '/relabel' or '/unlabel' commands. command = subtextNodes.find((node) => { - if ( - node === LABEL_COMMAND.LABEL || - node === LABEL_COMMAND.RELABEL || - node === LABEL_COMMAND.UNLABEL - ) { + if (Object.values(LABEL_COMMAND).includes(node)) { return node; } return null; @@ -621,7 +622,7 @@ class GfmAutoComplete { // The `LABEL_COMMAND.RELABEL` is intentionally skipped // because we want to return all the labels (unfiltered) for that command. - if (command === LABEL_COMMAND.LABEL) { + if (command === LABEL_COMMAND.LABEL || command === LABEL_COMMAND.LABELS) { // Return labels with set: undefined. return data.filter((label) => !label.set); } else if (command === LABEL_COMMAND.UNLABEL) { @@ -996,7 +997,7 @@ GfmAutoComplete.Issues = { return value.reference || '${atwho-at}${id}'; }, templateFunction({ id, title, reference }) { - return `<li><small>${reference || id}</small> ${escape(title)}</li>`; + return `<li><small>${escape(reference || id)}</small> ${escape(title)}</li>`; }, }; // Milestones diff --git a/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue b/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue index f17a05999b0..bf71f682048 100644 --- a/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue +++ b/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue @@ -2,7 +2,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { captureException } from '@sentry/browser'; import PipelineWizard from '~/pipeline_wizard/pipeline_wizard.vue'; -import PagesWizardTemplate from '~/pipeline_wizard/templates/pages.yml'; +import PagesWizardTemplate from '~/pipeline_wizard/templates/pages.yml?raw'; import { logError } from '~/lib/logger'; import { s__ } from '~/locale'; import { redirectTo } from '~/lib/utils/url_utility'; diff --git a/app/assets/javascripts/gitlab_version_check/components/security_patch_upgrade_alert.vue b/app/assets/javascripts/gitlab_version_check/components/security_patch_upgrade_alert.vue new file mode 100644 index 00000000000..89dc68ec73e --- /dev/null +++ b/app/assets/javascripts/gitlab_version_check/components/security_patch_upgrade_alert.vue @@ -0,0 +1,76 @@ +<script> +import { GlAlert, GlSprintf, GlLink, GlButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import Tracking from '~/tracking'; +import { UPGRADE_DOCS_URL, ABOUT_RELEASES_PAGE } from '../constants'; + +export default { + name: 'SecurityPatchUpgradeAlert', + i18n: { + alertTitle: s__('VersionCheck|Critical security upgrade available'), + alertBody: s__( + 'VersionCheck|You are currently on version %{currentVersion}. We strongly recommend upgrading your GitLab installation. %{link}', + ), + learnMore: s__('VersionCheck|Learn more about this critical security release.'), + primaryButtonText: s__('VersionCheck|Upgrade now'), + }, + components: { + GlAlert, + GlSprintf, + GlLink, + GlButton, + }, + mixins: [Tracking.mixin()], + props: { + currentVersion: { + type: String, + required: true, + }, + }, + mounted() { + this.track('render', { + label: 'security_patch_upgrade_alert', + property: this.currentVersion, + }); + }, + methods: { + trackLearnMoreClick() { + this.track('click_link', { + label: 'security_patch_upgrade_alert_learn_more', + property: this.currentVersion, + }); + }, + trackUpgradeNowClick() { + this.track('click_link', { + label: 'security_patch_upgrade_alert_upgrade_now', + property: this.currentVersion, + }); + }, + }, + UPGRADE_DOCS_URL, + ABOUT_RELEASES_PAGE, +}; +</script> + +<template> + <gl-alert :title="$options.i18n.alertTitle" variant="danger" :dismissible="false"> + <gl-sprintf :message="$options.i18n.alertBody"> + <template #currentVersion> + <span class="gl-font-weight-bold">{{ currentVersion }}</span> + </template> + <template #link> + <gl-link :href="$options.ABOUT_RELEASES_PAGE" @click="trackLearnMoreClick">{{ + $options.i18n.learnMore + }}</gl-link> + </template> + </gl-sprintf> + <template #actions> + <gl-button + :href="$options.UPGRADE_DOCS_URL" + variant="confirm" + @click="trackUpgradeNowClick" + >{{ $options.i18n.primaryButtonText }}</gl-button + > + </template> + </gl-alert> +</template> diff --git a/app/assets/javascripts/gitlab_version_check/components/security_patch_upgrade_alert_modal.vue b/app/assets/javascripts/gitlab_version_check/components/security_patch_upgrade_alert_modal.vue new file mode 100644 index 00000000000..4638ba8a268 --- /dev/null +++ b/app/assets/javascripts/gitlab_version_check/components/security_patch_upgrade_alert_modal.vue @@ -0,0 +1,160 @@ +<script> +import { GlModal, GlSprintf, GlLink, GlButton } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { glEmojiTag } from '~/emoji'; +import { s__, sprintf } from '~/locale'; +import Tracking from '~/tracking'; +import { getHideAlertModalCookie, setHideAlertModalCookie } from '../utils'; +import { + UPGRADE_DOCS_URL, + ABOUT_RELEASES_PAGE, + ALERT_MODAL_ID, + TRACKING_ACTIONS, + TRACKING_LABELS, +} from '../constants'; + +export default { + name: 'SecurityPatchUpgradeAlertModal', + i18n: { + modalTitle: s__('VersionCheck|Important notice - Critical security release'), + modalBodyNoStableVersions: s__( + 'VersionCheck|You are currently on version %{currentVersion}! We strongly recommend upgrading your GitLab installation immediately.', + ), + modalBodyStableVersions: s__( + 'VersionCheck|You are currently on version %{currentVersion}! We strongly recommend upgrading your GitLab installation to one of the following versions immediately: %{latestStableVersions}.', + ), + modalDetails: s__('VersionCheck|%{details}'), + learnMore: s__('VersionCheck|Learn more about this critical security release.'), + primaryButtonText: s__('VersionCheck|Upgrade now'), + secondaryButtonText: s__('VersionCheck|Remind me again in 3 days'), + }, + components: { + GlModal, + GlSprintf, + GlLink, + GlButton, + }, + directives: { + SafeHtml, + }, + mixins: [Tracking.mixin()], + props: { + currentVersion: { + type: String, + required: true, + }, + details: { + type: String, + required: false, + default: '', + }, + latestStableVersions: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + return { + visible: true, + }; + }, + computed: { + alertEmoji() { + return glEmojiTag('rotating_light'); + }, + modalBody() { + if (this.latestStableVersions?.length > 0) { + return this.$options.i18n.modalBodyStableVersions; + } + + return this.$options.i18n.modalBodyNoStableVersions; + }, + modalDetails() { + return sprintf(this.$options.i18n.modalDetails, { details: this.details }); + }, + latestStableVersionsStrings() { + return this.latestStableVersions?.length > 0 ? this.latestStableVersions.join(', ') : ''; + }, + }, + created() { + if (getHideAlertModalCookie(this.currentVersion)) { + this.visible = false; + return; + } + + this.dispatchTrackingEvent(TRACKING_ACTIONS.RENDER, TRACKING_LABELS.MODAL); + }, + methods: { + dispatchTrackingEvent(action, label) { + this.track(action, { + label, + property: this.currentVersion, + }); + }, + trackLearnMoreClick() { + this.dispatchTrackingEvent(TRACKING_ACTIONS.CLICK_LINK, TRACKING_LABELS.LEARN_MORE_LINK); + }, + trackRemindMeLaterClick() { + this.dispatchTrackingEvent(TRACKING_ACTIONS.CLICK_BUTTON, TRACKING_LABELS.REMIND_ME_BTN); + setHideAlertModalCookie(this.currentVersion); + this.$refs.alertModal.hide(); + }, + trackUpgradeNowClick() { + this.dispatchTrackingEvent(TRACKING_ACTIONS.CLICK_LINK, TRACKING_LABELS.UPGRADE_BTN_LINK); + setHideAlertModalCookie(this.currentVersion); + }, + trackModalDismissed() { + this.dispatchTrackingEvent(TRACKING_ACTIONS.CLICK_BUTTON, TRACKING_LABELS.DISMISS); + }, + }, + safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, + UPGRADE_DOCS_URL, + ABOUT_RELEASES_PAGE, + ALERT_MODAL_ID, +}; +</script> + +<template> + <gl-modal + ref="alertModal" + :modal-id="$options.ALERT_MODAL_ID" + :visible="visible" + @close="trackModalDismissed" + > + <template #modal-title> + <span v-safe-html:[$options.safeHtmlConfig]="alertEmoji"></span> + <span data-testid="alert-modal-title">{{ $options.i18n.modalTitle }}</span> + </template> + <template #default> + <div data-testid="alert-modal-body" class="gl-mb-6"> + <gl-sprintf :message="modalBody"> + <template #currentVersion> + <span class="gl-font-weight-bold">{{ currentVersion }}</span> + </template> + <template #latestStableVersions> + <span class="gl-font-weight-bold">{{ latestStableVersionsStrings }}</span> + </template> + </gl-sprintf> + </div> + <div v-if="details" data-testid="alert-modal-details" class="gl-mb-6"> + {{ modalDetails }} + </div> + <gl-link :href="$options.ABOUT_RELEASES_PAGE" @click="trackLearnMoreClick">{{ + $options.i18n.learnMore + }}</gl-link> + </template> + <template #modal-footer> + <gl-button data-testid="alert-modal-remind-button" @click="trackRemindMeLaterClick">{{ + $options.i18n.secondaryButtonText + }}</gl-button> + <gl-button + data-testid="alert-modal-upgrade-button" + :href="$options.UPGRADE_DOCS_URL" + variant="confirm" + @click="trackUpgradeNowClick" + >{{ $options.i18n.primaryButtonText }}</gl-button + > + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/gitlab_version_check/constants.js b/app/assets/javascripts/gitlab_version_check/constants.js index 259723a4e22..049397148ab 100644 --- a/app/assets/javascripts/gitlab_version_check/constants.js +++ b/app/assets/javascripts/gitlab_version_check/constants.js @@ -7,3 +7,25 @@ export const STATUS_TYPES = { }; export const UPGRADE_DOCS_URL = helpPagePath('update/index'); + +export const ABOUT_RELEASES_PAGE = 'https://about.gitlab.com/releases/categories/releases/'; + +export const ALERT_MODAL_ID = 'security-patch-upgrade-alert-modal'; + +export const COOKIE_EXPIRATION = 3; + +export const COOKIE_SUFFIX = '-hide-alert-modal'; + +export const TRACKING_ACTIONS = { + RENDER: 'render', + CLICK_LINK: 'click_link', + CLICK_BUTTON: 'click_button', +}; + +export const TRACKING_LABELS = { + MODAL: 'security_patch_upgrade_alert_modal', + LEARN_MORE_LINK: 'security_patch_upgrade_alert_modal_learn_more', + REMIND_ME_BTN: 'security_patch_upgrade_alert_modal_remind_3_days', + UPGRADE_BTN_LINK: 'security_patch_upgrade_alert_modal_upgrade_now', + DISMISS: 'security_patch_upgrade_alert_modal_close', +}; diff --git a/app/assets/javascripts/gitlab_version_check/index.js b/app/assets/javascripts/gitlab_version_check/index.js index 203ce10ef57..edb7e9abe49 100644 --- a/app/assets/javascripts/gitlab_version_check/index.js +++ b/app/assets/javascripts/gitlab_version_check/index.js @@ -1,50 +1,98 @@ import Vue from 'vue'; -import * as Sentry from '@sentry/browser'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import axios from '~/lib/utils/axios_utils'; -import { joinPaths } from '~/lib/utils/url_utility'; +import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import GitlabVersionCheckBadge from './components/gitlab_version_check_badge.vue'; +import SecurityPatchUpgradeAlert from './components/security_patch_upgrade_alert.vue'; +import SecurityPatchUpgradeAlertModal from './components/security_patch_upgrade_alert_modal.vue'; -const mountGitlabVersionCheckBadge = ({ el, status }) => { - const { size } = el.dataset; +const mountGitlabVersionCheckBadge = (el) => { + const { size, version } = el.dataset; const actionable = parseBoolean(el.dataset.actionable); - return new Vue({ - el, - render(createElement) { - return createElement(GitlabVersionCheckBadge, { - props: { - size, - actionable, - status, - }, - }); - }, - }); + try { + const { severity } = JSON.parse(version); + + // If no severity (status) data don't worry about rendering + if (!severity) { + return null; + } + + return new Vue({ + el, + render(createElement) { + return createElement(GitlabVersionCheckBadge, { + props: { + size, + actionable, + status: severity, + }, + }); + }, + }); + } catch { + return null; + } }; -export default async () => { - const versionCheckBadges = [...document.querySelectorAll('.js-gitlab-version-check-badge')]; +const mountSecurityPatchUpgradeAlert = (el) => { + const { currentVersion } = el.dataset; - // If there are no version check elements, exit out - if (versionCheckBadges?.length <= 0) { + try { + return new Vue({ + el, + render(createElement) { + return createElement(SecurityPatchUpgradeAlert, { + props: { + currentVersion, + }, + }); + }, + }); + } catch { return null; } +}; - const status = await axios - .get(joinPaths('/', gon.relative_url_root, '/admin/version_check.json')) - .then((res) => { - return res.data?.severity; - }) - .catch((e) => { - Sentry.captureException(e); - return null; +const mountSecurityPatchUpgradeAlertModal = (el) => { + const { currentVersion, version } = el.dataset; + + try { + const { details, latestStableVersions } = convertObjectPropsToCamelCase(JSON.parse(version)); + + return new Vue({ + el, + render(createElement) { + return createElement(SecurityPatchUpgradeAlertModal, { + props: { + currentVersion, + details, + latestStableVersions, + }, + }); + }, }); + } catch { + return null; + } +}; + +export default () => { + const renderedApps = []; - // If we don't have a status there is nothing to render - if (status) { - return versionCheckBadges.map((el) => mountGitlabVersionCheckBadge({ el, status })); + const securityPatchUpgradeAlert = document.getElementById('js-security-patch-upgrade-alert'); + const securityPatchUpgradeAlertModal = document.getElementById( + 'js-security-patch-upgrade-alert-modal', + ); + const versionCheckBadges = [...document.querySelectorAll('.js-gitlab-version-check-badge')]; + + if (securityPatchUpgradeAlert) { + renderedApps.push(mountSecurityPatchUpgradeAlert(securityPatchUpgradeAlert)); } - return null; + if (securityPatchUpgradeAlertModal) { + renderedApps.push(mountSecurityPatchUpgradeAlertModal(securityPatchUpgradeAlertModal)); + } + + renderedApps.push(...versionCheckBadges.map((el) => mountGitlabVersionCheckBadge(el))); + + return renderedApps; }; diff --git a/app/assets/javascripts/gitlab_version_check/utils.js b/app/assets/javascripts/gitlab_version_check/utils.js new file mode 100644 index 00000000000..d2f4349483c --- /dev/null +++ b/app/assets/javascripts/gitlab_version_check/utils.js @@ -0,0 +1,18 @@ +import { setCookie, getCookie, parseBoolean } from '~/lib/utils/common_utils'; +import { COOKIE_EXPIRATION, COOKIE_SUFFIX } from './constants'; + +const buildKey = (currentVersion) => { + return `${currentVersion}${COOKIE_SUFFIX}`; +}; + +export const setHideAlertModalCookie = (currentVersion) => { + const key = buildKey(currentVersion); + + setCookie(key, true, { expires: COOKIE_EXPIRATION }); +}; + +export const getHideAlertModalCookie = (currentVersion) => { + const key = buildKey(currentVersion); + + return parseBoolean(getCookie(key)); +}; diff --git a/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql index 64f547f933a..3ecaee435e2 100644 --- a/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql @@ -1,4 +1,5 @@ fragment AlertListItem on AlertManagementAlert { + id iid title severity diff --git a/app/assets/javascripts/graphql_shared/mutations/alert_status_update.mutation.graphql b/app/assets/javascripts/graphql_shared/mutations/alert_status_update.mutation.graphql index ba1e607bc10..9ec87ba291d 100644 --- a/app/assets/javascripts/graphql_shared/mutations/alert_status_update.mutation.graphql +++ b/app/assets/javascripts/graphql_shared/mutations/alert_status_update.mutation.graphql @@ -4,6 +4,7 @@ mutation updateAlertStatus($projectPath: ID!, $status: AlertManagementStatus!, $ updateAlertStatus(input: { iid: $iid, status: $status, projectPath: $projectPath }) { errors alert { + id iid status endedAt diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json index e8b0174b8f6..5467105ac3c 100644 --- a/app/assets/javascripts/graphql_shared/possible_types.json +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -7,10 +7,12 @@ "CiGroupVariable", "CiInstanceVariable", "CiManualVariable", - "CiProjectVariable" + "CiProjectVariable", + "PipelineScheduleVariable" ], "CommitSignature": [ "GpgSignature", + "SshSignature", "X509Signature" ], "CurrentUserTodos": [ @@ -144,10 +146,13 @@ "WorkItemWidget": [ "WorkItemWidgetAssignees", "WorkItemWidgetDescription", + "WorkItemWidgetHealthStatus", "WorkItemWidgetHierarchy", "WorkItemWidgetIteration", "WorkItemWidgetLabels", "WorkItemWidgetMilestone", + "WorkItemWidgetNotes", + "WorkItemWidgetProgress", "WorkItemWidgetStartAndDueDate", "WorkItemWidgetStatus", "WorkItemWidgetWeight" diff --git a/app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql b/app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql index 8debc6113d1..77b95bb8910 100644 --- a/app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql @@ -5,6 +5,7 @@ query alertDetails($fullPath: ID!, $alertId: String) { id alertManagementAlerts(iid: $alertId) { nodes { + id ...AlertDetailItem } } diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index 15f5a3518a5..46d5341ea97 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -1,21 +1,25 @@ <script> -import { GlLoadingIcon, GlModal } from '@gitlab/ui'; +import { GlLoadingIcon, GlModal, GlEmptyState } from '@gitlab/ui'; import { createAlert } from '~/flash'; import { mergeUrlParams, getParameterByName } from '~/lib/utils/url_utility'; -import { HIDDEN_CLASS } from '~/lib/utils/constants'; import { __, s__, sprintf } from '~/locale'; -import { COMMON_STR, CONTENT_LIST_CLASS } from '../constants'; +import { COMMON_STR } from '../constants'; import eventHub from '../event_hub'; import GroupsComponent from './groups.vue'; -import EmptyState from './empty_state.vue'; export default { + i18n: { + searchEmptyState: { + title: __('No results found'), + description: __('Edit your search and try again'), + }, + }, components: { GroupsComponent, GlModal, GlLoadingIcon, - EmptyState, + GlEmptyState, }, props: { action: { @@ -40,20 +44,14 @@ export default { type: Boolean, required: true, }, - renderEmptyState: { - type: Boolean, - required: false, - default: false, - }, }, data() { return { isModalVisible: false, isLoading: true, - isSearchEmpty: false, + fromSearch: false, targetGroup: null, targetParentGroup: null, - showEmptyState: false, }; }, computed: { @@ -79,6 +77,9 @@ export default { groups() { return this.store.getGroups(); }, + hasGroups() { + return this.groups && this.groups.length > 0; + }, pageInfo() { return this.store.getPaginationInfo(); }, @@ -231,47 +232,17 @@ export default { this.targetGroup.isBeingRemoved = false; }); }, - showLegacyEmptyState() { - const { containerEl } = this; - - if (!containerEl) return; - - const contentListEl = containerEl.querySelector(CONTENT_LIST_CLASS); - const emptyStateEl = containerEl.querySelector('.empty-state'); - - if (contentListEl) { - contentListEl.remove(); - } - - if (emptyStateEl) { - emptyStateEl.classList.remove(HIDDEN_CLASS); - } - }, updatePagination(headers) { this.store.setPaginationInfo(headers); }, updateGroups(groups, fromSearch) { - const hasGroups = groups && groups.length > 0; - - if (this.renderEmptyState) { - this.isSearchEmpty = fromSearch && !hasGroups; - } else { - this.isSearchEmpty = !hasGroups; - } + this.fromSearch = fromSearch; if (fromSearch) { this.store.setSearchedGroups(groups); } else { this.store.setGroups(groups); } - - if (this.action && !hasGroups && !fromSearch) { - if (this.renderEmptyState) { - this.showEmptyState = true; - } else { - this.showLegacyEmptyState(); - } - } }, }, }; @@ -285,14 +256,16 @@ export default { size="lg" class="loading-animation prepend-top-20" /> - <groups-component - v-else - :groups="groups" - :search-empty="isSearchEmpty" - :page-info="pageInfo" - :action="action" - /> - <empty-state v-if="showEmptyState" /> + <template v-else> + <groups-component v-if="hasGroups" :groups="groups" :page-info="pageInfo" :action="action" /> + <gl-empty-state + v-else-if="fromSearch" + :title="$options.i18n.searchEmptyState.title" + :description="$options.i18n.searchEmptyState.description" + data-testid="search-empty-state" + /> + <slot v-else name="empty-state"></slot> + </template> <gl-modal modal-id="leave-group-modal" :visible="isModalVisible" diff --git a/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue new file mode 100644 index 00000000000..535758750f9 --- /dev/null +++ b/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue @@ -0,0 +1,21 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; + +import { s__ } from '~/locale'; + +export default { + components: { GlEmptyState }, + i18n: { + title: s__('GroupsEmptyState|No archived projects.'), + }, + inject: ['newProjectIllustration'], +}; +</script> + +<template> + <gl-empty-state + :title="$options.i18n.title" + :svg-path="newProjectIllustration" + :svg-height="100" + /> +</template> diff --git a/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue new file mode 100644 index 00000000000..7223321bf3e --- /dev/null +++ b/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue @@ -0,0 +1,21 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; + +import { s__ } from '~/locale'; + +export default { + components: { GlEmptyState }, + i18n: { + title: s__('GroupsEmptyState|No shared projects.'), + }, + inject: ['newProjectIllustration'], +}; +</script> + +<template> + <gl-empty-state + :title="$options.i18n.title" + :svg-path="newProjectIllustration" + :svg-height="100" + /> +</template> diff --git a/app/assets/javascripts/groups/components/empty_state.vue b/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue index 4219b52737d..955cb1ca63e 100644 --- a/app/assets/javascripts/groups/components/empty_state.vue +++ b/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue @@ -83,7 +83,6 @@ export default { </div> <gl-empty-state v-else - class="gl-mt-5" :title="$options.i18n.withoutLinks.title" :svg-path="emptySubgroupIllustration" :description="$options.i18n.withoutLinks.description" diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 961af800971..d9781ef9c84 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -9,8 +9,8 @@ import { GlPopover, GlLink, GlTooltipDirective, - GlSafeHtmlDirective, } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { visitUrl } from '~/lib/utils/url_utility'; import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; @@ -29,7 +29,7 @@ import ItemTypeIcon from './item_type_icon.vue'; export default { directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, components: { GlAvatar, @@ -200,11 +200,9 @@ export default { class="no-expand 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 - group.name - }} + <!-- ending bracket must be by closing tag to prevent --> + <!-- link hover text-decoration from over-extending --> + {{ group.name }} </a> <gl-icon v-gl-tooltip.hover.bottom diff --git a/app/assets/javascripts/groups/components/group_name_and_path.vue b/app/assets/javascripts/groups/components/group_name_and_path.vue index 9a1ea2f1812..5f997ecc7ba 100644 --- a/app/assets/javascripts/groups/components/group_name_and_path.vue +++ b/app/assets/javascripts/groups/components/group_name_and_path.vue @@ -59,7 +59,7 @@ export default { learnMore: s__('Groups|Learn more'), }, inputSize: { md: 'lg' }, - changingGroupPathHelpPagePath: helpPagePath('user/group/index', { + changingGroupPathHelpPagePath: helpPagePath('user/group/manage', { anchor: 'change-a-groups-path', }), mattermostDataBindName: 'create_chat_team', diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue index 43aa0753082..5075be62214 100644 --- a/app/assets/javascripts/groups/components/groups.vue +++ b/app/assets/javascripts/groups/components/groups.vue @@ -1,5 +1,4 @@ <script> -import { GlEmptyState } from '@gitlab/ui'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; import { getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -12,7 +11,6 @@ export default { }, components: { PaginationLinks, - GlEmptyState, }, props: { groups: { @@ -23,10 +21,6 @@ export default { type: Object, required: true, }, - searchEmpty: { - type: Boolean, - required: true, - }, action: { type: String, required: false, @@ -46,18 +40,11 @@ export default { <template> <div class="groups-list-tree-container" data-qa-selector="groups_list_tree_container"> - <gl-empty-state - v-if="searchEmpty" - :title="$options.i18n.emptyStateTitle" - :description="$options.i18n.emptyStateDescription" + <group-folder :groups="groups" :action="action" /> + <pagination-links + :change="change" + :page-info="pageInfo" + class="d-flex justify-content-center gl-mt-3" /> - <template v-else> - <group-folder :groups="groups" :action="action" /> - <pagination-links - :change="change" - :page-info="pageInfo" - class="d-flex justify-content-center gl-mt-3" - /> - </template> </div> </template> diff --git a/app/assets/javascripts/groups/components/overview_tabs.vue b/app/assets/javascripts/groups/components/overview_tabs.vue index 46ab30367a0..79a2e11b0bb 100644 --- a/app/assets/javascripts/groups/components/overview_tabs.vue +++ b/app/assets/javascripts/groups/components/overview_tabs.vue @@ -13,19 +13,32 @@ import { } from '../constants'; import eventHub from '../event_hub'; import GroupsApp from './app.vue'; +import SubgroupsAndProjectsEmptyState from './empty_states/subgroups_and_projects_empty_state.vue'; +import SharedProjectsEmptyState from './empty_states/shared_projects_empty_state.vue'; +import ArchivedProjectsEmptyState from './empty_states/archived_projects_empty_state.vue'; const [SORTING_ITEM_NAME] = OVERVIEW_TABS_SORTING_ITEMS; const MIN_SEARCH_LENGTH = 3; export default { - components: { GlTabs, GlTab, GroupsApp, GlSearchBoxByType, GlSorting, GlSortingItem }, + components: { + GlTabs, + GlTab, + GroupsApp, + GlSearchBoxByType, + GlSorting, + GlSortingItem, + SubgroupsAndProjectsEmptyState, + SharedProjectsEmptyState, + ArchivedProjectsEmptyState, + }, inject: ['endpoints', 'initialSort'], data() { const tabs = [ { title: this.$options.i18n[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS], key: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, - renderEmptyState: true, + emptyStateComponent: SubgroupsAndProjectsEmptyState, lazy: this.$route.name !== ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, service: new GroupsService(this.endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]), store: new GroupsStore({ showSchemaMarkup: true }), @@ -33,7 +46,7 @@ export default { { title: this.$options.i18n[ACTIVE_TAB_SHARED], key: ACTIVE_TAB_SHARED, - renderEmptyState: false, + emptyStateComponent: SharedProjectsEmptyState, lazy: this.$route.name !== ACTIVE_TAB_SHARED, service: new GroupsService(this.endpoints[ACTIVE_TAB_SHARED]), store: new GroupsStore(), @@ -41,7 +54,7 @@ export default { { title: this.$options.i18n[ACTIVE_TAB_ARCHIVED], key: ACTIVE_TAB_ARCHIVED, - renderEmptyState: false, + emptyStateComponent: ArchivedProjectsEmptyState, lazy: this.$route.name !== ACTIVE_TAB_ARCHIVED, service: new GroupsService(this.endpoints[ACTIVE_TAB_ARCHIVED]), store: new GroupsStore(), @@ -158,18 +171,16 @@ export default { <template> <gl-tabs content-class="gl-pt-0" :value="activeTabIndex" @input="handleTabInput"> <gl-tab - v-for="{ key, title, renderEmptyState, lazy, service, store } in tabs" + v-for="{ key, title, emptyStateComponent, lazy, service, store } in tabs" :key="key" :title="title" :lazy="lazy" > - <groups-app - :action="key" - :service="service" - :store="store" - :hide-projects="false" - :render-empty-state="renderEmptyState" - /> + <groups-app :action="key" :service="service" :store="store" :hide-projects="false"> + <template v-if="emptyStateComponent" #empty-state> + <component :is="emptyStateComponent" /> + </template> + </groups-app> </gl-tab> <template #tabs-end> <li class="gl-flex-grow-1 gl-align-self-center gl-w-full gl-lg-w-auto gl-py-2"> diff --git a/app/assets/javascripts/groups/components/transfer_group_form.vue b/app/assets/javascripts/groups/components/transfer_group_form.vue index 15a193f7cb8..3da417ebf0a 100644 --- a/app/assets/javascripts/groups/components/transfer_group_form.vue +++ b/app/assets/javascripts/groups/components/transfer_group_form.vue @@ -73,6 +73,7 @@ export default { :disabled="disableSubmitButton" :phrase="confirmationPhrase" :button-text="confirmButtonText" + button-qa-selector="transfer_group_button" @confirm="$emit('confirm')" /> </div> diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index 4d03a523486..f58781fa9ec 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -1,7 +1,9 @@ import Vue from 'vue'; +import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; import { highCountTrim } from '~/lib/utils/text_utility'; import Tracking from '~/tracking'; import Translate from '~/vue_shared/translate'; +import { parseBoolean } from '~/lib/utils/common_utils'; /** * Updates todo counter when todos are toggled. @@ -99,6 +101,7 @@ function trackShowUserDropdownLink(trackEvent, elToTrack, el) { }); }); } + export function initNavUserDropdownTracking() { const el = document.querySelector('.js-nav-user-dropdown'); const buyEl = document.querySelector('.js-buy-pipeline-minutes-link'); @@ -108,5 +111,23 @@ export function initNavUserDropdownTracking() { } } +function initNewNavToggle() { + const el = document.querySelector('.js-new-nav-toggle'); + if (!el) return false; + + return new Vue({ + el, + render(h) { + return h(NewNavToggle, { + props: { + enabled: parseBoolean(el.dataset.enabled), + endpoint: el.dataset.endpoint, + }, + }); + }, + }); +} + requestIdleCallback(initStatusTriggers); requestIdleCallback(initNavUserDropdownTracking); +requestIdleCallback(initNewNavToggle); diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue index 8fc0ce48e61..bf5daf29b21 100644 --- a/app/assets/javascripts/header_search/components/app.vue +++ b/app/assets/javascripts/header_search/components/app.vue @@ -4,7 +4,6 @@ import { GlOutsideDirective as Outside, GlIcon, GlToken, - GlSafeHtmlDirective as SafeHtml, GlTooltipDirective, GlResizeObserverDirective, } from '@gitlab/ui'; @@ -56,7 +55,7 @@ export default { false, ), }, - directives: { SafeHtml, Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective }, + directives: { Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective }, components: { GlSearchBoxByType, HeaderSearchDefaultItems, diff --git a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue index 025c48f355d..c85fb4f4158 100644 --- a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue +++ b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue @@ -6,9 +6,9 @@ import { GlAvatar, GlAlert, GlLoadingIcon, - GlSafeHtmlDirective as SafeHtml, } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__ } from '~/locale'; import highlight from '~/lib/utils/highlight'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js index 332ccee510f..cda3379309c 100644 --- a/app/assets/javascripts/header_search/constants.js +++ b/app/assets/javascripts/header_search/constants.js @@ -26,6 +26,8 @@ export const GROUPS_CATEGORY = s__('GlobalSearch|Groups'); export const PROJECTS_CATEGORY = s__('GlobalSearch|Projects'); +export const USERS_CATEGORY = s__('GlobalSearch|Users'); + export const ISSUES_CATEGORY = s__('GlobalSearch|Recent issues'); export const MERGE_REQUEST_CATEGORY = s__('GlobalSearch|Recent merge requests'); @@ -68,6 +70,7 @@ export const DROPDOWN_ORDER = [ RECENT_EPICS_CATEGORY, GROUPS_CATEGORY, PROJECTS_CATEGORY, + USERS_CATEGORY, IN_THIS_PROJECT_CATEGORY, SETTINGS_CATEGORY, HELP_CATEGORY, diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index d02dc67d933..ef3da57c240 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -1,6 +1,7 @@ <script> -import { GlModal, GlSafeHtmlDirective, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { GlModal, GlButton, GlTooltipDirective } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { n__ } from '~/locale'; import { leftSidebarViews, MAX_WINDOW_HEIGHT_COMPACT } from '../../constants'; import { createUnexpectedCommitError } from '../../lib/errors'; @@ -17,7 +18,7 @@ export default { GlButton, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, GlTooltip: GlTooltipDirective, }, data() { diff --git a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue index 5272c4310d8..dd343bc5f79 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue @@ -1,6 +1,6 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { mapState } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; export default { directives: { diff --git a/app/assets/javascripts/ide/components/error_message.vue b/app/assets/javascripts/ide/components/error_message.vue index 67eedc6b37f..eba9bbcdf09 100644 --- a/app/assets/javascripts/ide/components/error_message.vue +++ b/app/assets/javascripts/ide/components/error_message.vue @@ -1,6 +1,7 @@ <script> -import { GlAlert, GlLoadingIcon, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import { mapActions } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; export default { components: { @@ -8,7 +9,7 @@ export default { GlLoadingIcon, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { message: { diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue index 8d6a0b99e0c..9676233a443 100644 --- a/app/assets/javascripts/ide/components/jobs/detail.vue +++ b/app/assets/javascripts/ide/components/jobs/detail.vue @@ -1,7 +1,8 @@ <script> -import { GlTooltipDirective, GlButton, GlIcon, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlTooltipDirective, GlButton, GlIcon } from '@gitlab/ui'; import { throttle } from 'lodash'; import { mapActions, mapState } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __ } from '~/locale'; import JobDescription from './detail/description.vue'; import ScrollButton from './detail/scroll_button.vue'; @@ -14,7 +15,7 @@ const scrollPositions = { export default { directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, components: { GlButton, diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue index 9a529bdcee1..ea1dbee4669 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/index.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -80,7 +80,7 @@ export default { @click="createNewItem('blob')" /> </li> - <li><upload :path="path" @create="createTempEntry" /></li> + <upload :path="path" @create="createTempEntry" /> <li> <item-button :label="__('New directory')" diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue index 76d8a0aff3d..7c10e055e91 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -65,7 +65,7 @@ export default { </script> <template> - <div> + <li> <item-button :class="buttonCssClasses" :show-label="showLabel" @@ -84,5 +84,5 @@ export default { data-qa-selector="file_upload_field" @change="openFile" /> - </div> + </li> </template> diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue index c74a5052573..da2d4fbe7f0 100644 --- a/app/assets/javascripts/ide/components/panes/right.vue +++ b/app/assets/javascripts/ide/components/panes/right.vue @@ -7,7 +7,6 @@ import PipelinesList from '../pipelines/list.vue'; import Clientside from '../preview/clientside.vue'; import ResizablePanel from '../resizable_panel.vue'; import TerminalView from '../terminal/view.vue'; -import SwitchEditorsView from '../switch_editors/switch_editors_view.vue'; import CollapsibleSidebar from './collapsible_sidebar.vue'; // Need to add the width of the nav buttons since the resizable container contains those as well @@ -21,7 +20,7 @@ export default { }, computed: { ...mapState('terminal', { isTerminalVisible: 'isVisible' }), - ...mapState(['currentMergeRequestId', 'clientsidePreviewEnabled', 'canUseNewWebIde']), + ...mapState(['currentMergeRequestId', 'clientsidePreviewEnabled']), ...mapGetters(['packageJson']), ...mapState('rightPane', ['isOpen']), showLivePreview() { @@ -30,12 +29,6 @@ export default { rightExtensionTabs() { return [ { - show: this.canUseNewWebIde, - title: __('Switch editors'), - views: [{ component: SwitchEditorsView, ...rightSidebarViews.switchEditors }], - icon: 'bullhorn', - }, - { show: true, title: __('Pipelines'), views: [ @@ -60,7 +53,6 @@ export default { }, }, WIDTH, - SWITCH_EDITORS_VIEW_NAME: rightSidebarViews.switchEditors.name, }; </script> @@ -72,11 +64,6 @@ export default { :min-size="$options.WIDTH" :resizable="isOpen" > - <collapsible-sidebar - class="gl-w-full" - :extension-tabs="rightExtensionTabs" - :init-open-view="$options.SWITCH_EDITORS_VIEW_NAME" - side="right" - /> + <collapsible-sidebar class="gl-w-full" :extension-tabs="rightExtensionTabs" side="right" /> </resizable-panel> </template> diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index 7f513afe82e..7f662f528d7 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -1,17 +1,8 @@ <script> -import { - GlLoadingIcon, - GlIcon, - GlSafeHtmlDirective as SafeHtml, - GlTabs, - GlTab, - GlBadge, - GlAlert, -} from '@gitlab/ui'; -import { escape } from 'lodash'; +import { GlLoadingIcon, GlIcon, GlTabs, GlTab, GlBadge, GlAlert } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import IDEServices from '~/ide/services'; -import { sprintf, __ } from '~/locale'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import JobsList from '../jobs/list.vue'; import EmptyState from './empty_state.vue'; @@ -48,16 +39,6 @@ export default { 'stages', 'isLoadingJobs', ]), - ciLintText() { - return sprintf( - __('You can test your .gitlab-ci.yml in %{linkStart}CI Lint%{linkEnd}.'), - { - linkStart: `<a href="${escape(this.currentProject.web_url)}/-/ci/lint">`, - linkEnd: '</a>', - }, - false, - ); - }, showLoadingIcon() { return this.isLoadingPipeline && !this.hasLoadedPipeline; }, @@ -101,9 +82,8 @@ export default { :dismissible="false" class="gl-mt-5" > - <p class="gl-mb-0">{{ __('Found errors in your .gitlab-ci.yml:') }}</p> + <p class="gl-mb-0">{{ __('Unable to create pipeline') }}</p> <p class="gl-mb-0 break-word">{{ latestPipeline.yamlError }}</p> - <p v-safe-html="ciLintText" class="gl-mb-0"></p> </gl-alert> <gl-tabs v-else> <gl-tab :active="!pipelineFailed"> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 5f35dbdc5e7..3c9c0b1ade1 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -7,6 +7,7 @@ import { EDITOR_TYPE_CODE, EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN, + EXTENSION_CI_SCHEMA_FILE_NAME_MATCH, } from '~/editor/constants'; import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext'; @@ -26,6 +27,7 @@ import { performanceMarkAndMeasure } from '~/performance/utils'; import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { leftSidebarViews, viewerTypes, @@ -53,6 +55,7 @@ export default { DiffViewer, FileTemplatesBar, }, + mixins: [glFeatureFlagMixin()], props: { file: { type: Object, @@ -145,6 +148,12 @@ export default { showTabs() { return !this.shouldHideEditor && this.isEditModeActive && this.previewMode; }, + isCiConfigFile() { + return ( + this.file.path === EXTENSION_CI_SCHEMA_FILE_NAME_MATCH && + this.editor?.getEditorType() === EDITOR_TYPE_CODE + ); + }, }, watch: { 'file.name': { @@ -232,8 +241,6 @@ export default { return; } - this.registerSchemaForFile(); - Promise.all([this.fetchFileData(), this.fetchEditorconfigRules()]) .then(() => { this.createEditorInstance(); @@ -357,6 +364,8 @@ export default { this.model.updateOptions(this.rules); + this.registerSchemaForFile(); + this.model.onChange((model) => { const { file } = model; if (!file.active) return; @@ -446,8 +455,33 @@ export default { return Promise.resolve(); }, registerSchemaForFile() { - const schema = this.getJsonSchemaForPath(this.file.path); - registerSchema(schema); + const registerExternalSchema = () => { + const schema = this.getJsonSchemaForPath(this.file.path); + return registerSchema(schema); + }; + const registerLocalSchema = async () => { + if (!this.CiSchemaExtension) { + const { CiSchemaExtension } = await import( + '~/editor/extensions/source_editor_ci_schema_ext' + ).catch((e) => + createAlert({ + message: e, + }), + ); + this.CiSchemaExtension = CiSchemaExtension; + } + this.editor.use({ definition: this.CiSchemaExtension }); + this.editor.registerCiSchema(); + }; + + if (this.isCiConfigFile && this.glFeatures.schemaLinting) { + registerLocalSchema(); + } else { + if (this.CiSchemaExtension) { + this.editor.unuse(this.CiSchemaExtension); + } + registerExternalSchema(); + } }, updateEditor(data) { // Looks like our model wrapper `.dispose` causes the monaco editor to emit some position changes after diff --git a/app/assets/javascripts/ide/components/switch_editors/switch_editors_view.vue b/app/assets/javascripts/ide/components/switch_editors/switch_editors_view.vue deleted file mode 100644 index 00164f65e33..00000000000 --- a/app/assets/javascripts/ide/components/switch_editors/switch_editors_view.vue +++ /dev/null @@ -1,103 +0,0 @@ -<script> -import { GlButton, GlEmptyState, GlLink } from '@gitlab/ui'; -import { mapState } from 'vuex'; -import { createAlert } from '~/flash'; -import { logError } from '~/lib/logger'; -import axios from '~/lib/utils/axios_utils'; -import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; -import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; -import { s__, __ } from '~/locale'; -import eventHub from '../../eventhub'; - -export const MSG_DESCRIPTION = s__('WebIDE|You are invited to experience the new Web IDE.'); -export const MSG_BUTTON_TEXT = s__('WebIDE|Switch to new Web IDE'); -export const MSG_LEARN_MORE = __('Learn more'); -export const MSG_TITLE = s__('WebIDE|Ready for something new?'); - -export const MSG_CONFIRM = s__( - 'WebIDE|Are you sure you want to switch editors? You will lose any unsaved changes.', -); -export const MSG_ERROR_ALERT = s__( - 'WebIDE|Something went wrong while updating the user preferences. Please see developer console for details.', -); - -export default { - components: { - GlButton, - GlEmptyState, - GlLink, - }, - data() { - return { - loading: false, - }; - }, - computed: { - ...mapState(['switchEditorSvgPath', 'links', 'userPreferencesPath']), - }, - methods: { - async submitSwitch() { - const confirmed = await confirmAction(MSG_CONFIRM, { - primaryBtnText: __('Switch editors'), - cancelBtnText: __('Cancel'), - }); - - if (!confirmed) { - return; - } - - try { - await axios.put(this.userPreferencesPath, { - user: { use_legacy_web_ide: false }, - }); - } catch (e) { - // why: We do not want to translate console logs - // eslint-disable-next-line @gitlab/require-i18n-strings - logError('Error while updating user preferences', e); - createAlert({ - message: MSG_ERROR_ALERT, - }); - return; - } - - eventHub.$emit('skip-beforeunload'); - window.location.reload(); - }, - // what: ignoreWhilePending prevents double confirmation boxes - onSwitchClicked: ignoreWhilePending(async function onSwitchClicked() { - this.loading = true; - - try { - await this.submitSwitch(); - } finally { - this.loading = false; - } - }), - }, - MSG_TITLE, - MSG_DESCRIPTION, - MSG_BUTTON_TEXT, - MSG_LEARN_MORE, -}; -</script> - -<template> - <div class="gl-h-full gl-display-flex gl-flex-direction-column gl-justify-content-center"> - <gl-empty-state :svg-path="switchEditorSvgPath" :svg-height="150" :title="$options.MSG_TITLE"> - <template #description> - <span>{{ $options.MSG_DESCRIPTION }}</span> - <gl-link :href="links.newWebIDEHelpPagePath">{{ $options.MSG_LEARN_MORE }}</gl-link - >. - </template> - <template #actions> - <gl-button - category="primary" - variant="confirm" - :loading="loading" - @click="onSwitchClicked" - >{{ $options.MSG_BUTTON_TEXT }}</gl-button - > - </template> - </gl-empty-state> - </div> -</template> diff --git a/app/assets/javascripts/ide/components/terminal/empty_state.vue b/app/assets/javascripts/ide/components/terminal/empty_state.vue index 623ba719b28..fa93f6d42a5 100644 --- a/app/assets/javascripts/ide/components/terminal/empty_state.vue +++ b/app/assets/javascripts/ide/components/terminal/empty_state.vue @@ -1,5 +1,6 @@ <script> -import { GlLoadingIcon, GlButton, GlAlert, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlLoadingIcon, GlButton, GlAlert } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; export default { components: { @@ -8,7 +9,7 @@ export default { GlAlert, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { isLoading: { diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index c8e737fa6f5..01ce5fa07ee 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -61,7 +61,6 @@ export const leftSidebarViews = { }; export const rightSidebarViews = { - switchEditors: { name: 'switch-editors', keepAlive: true }, pipelines: { name: 'pipelines-list', keepAlive: true }, jobsDetail: { name: 'jobs-detail', keepAlive: false }, mergeRequestInfo: { name: 'merge-request-info', keepAlive: true }, @@ -119,3 +118,5 @@ export const DEFAULT_BRANCH = 'main'; // Ping Usage Metrics Keys export const PING_USAGE_PREVIEW_KEY = 'web_ide_clientside_preview'; export const PING_USAGE_PREVIEW_SUCCESS_KEY = 'web_ide_clientside_preview_success'; + +export const GITLAB_WEB_IDE_FEEDBACK_ISSUE = 'https://gitlab.com/gitlab-org/gitlab/-/issues/377367'; diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index dec282239d9..1347d92b3b7 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -8,7 +8,6 @@ import { parseBoolean } from '../lib/utils/common_utils'; import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; import ide from './components/ide.vue'; import { createRouter } from './ide_router'; -import { initGitlabWebIDE } from './init_gitlab_web_ide'; import { DEFAULT_THEME } from './lib/themes'; import { createStore } from './stores'; @@ -74,7 +73,6 @@ export const initLegacyWebIDE = (el, options = {}) => { codesandboxBundlerUrl: el.dataset.codesandboxBundlerUrl, environmentsGuidanceAlertDismissed: !parseBoolean(el.dataset.enableEnvironmentsGuidance), previewMarkdownPath: el.dataset.previewMarkdownPath, - canUseNewWebIde: parseBoolean(el.dataset.canUseNewWebIde), userPreferencesPath: el.dataset.userPreferencesPath, }); }, @@ -96,7 +94,7 @@ export const initLegacyWebIDE = (el, options = {}) => { * * @param {Objects} options - Extra options for the IDE (Used by EE). */ -export function startIde(options) { +export async function startIde(options) { const ideElement = document.getElementById('ide'); if (!ideElement) { @@ -106,6 +104,7 @@ export function startIde(options) { const useNewWebIde = parseBoolean(ideElement.dataset.useNewWebIde); if (useNewWebIde) { + const { initGitlabWebIDE } = await import('./init_gitlab_web_ide'); initGitlabWebIDE(ideElement); } else { resetServiceWorkersPublicPath(); diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js index 140f2895a29..d3c64754e8a 100644 --- a/app/assets/javascripts/ide/init_gitlab_web_ide.js +++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js @@ -1,29 +1,89 @@ -import { cleanTrailingSlash } from './stores/utils'; +import { start } from '@gitlab/web-ide'; +import { __ } from '~/locale'; +import { cleanLeadingSeparator } from '~/lib/utils/url_utility'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_action'; +import { createAndSubmitForm } from '~/lib/utils/create_and_submit_form'; +import csrf from '~/lib/utils/csrf'; +import { getBaseConfig } from './lib/gitlab_web_ide/get_base_config'; +import { setupRootElement } from './lib/gitlab_web_ide/setup_root_element'; +import { GITLAB_WEB_IDE_FEEDBACK_ISSUE } from './constants'; -export const initGitlabWebIDE = async (el) => { - const { start } = await import('@gitlab/web-ide'); +const buildRemoteIdeURL = (ideRemotePath, remoteHost, remotePathArg) => { + const remotePath = cleanLeadingSeparator(remotePathArg); - const { gitlab_url: gitlabUrl } = window.gon; - const baseUrl = new URL(process.env.GITLAB_WEB_IDE_PUBLIC_PATH, window.location.origin); + const replacers = { + ':remote_host': encodeURIComponent(remoteHost), + ':remote_path': encodeURIComponent(remotePath).replaceAll('%2F', '/'), + }; - // what: Pull what we need from the element. We will replace it soon. - const { cspNonce: nonce, branchName: ref, projectPath } = el.dataset; + // why: Use the function callback of "replace" so we replace both keys at once + return ideRemotePath.replace(/(:remote_host|:remote_path)/g, (key) => { + return replacers[key]; + }); +}; + +const getMRTargetProject = () => { + const url = new URL(window.location.href); + + return url.searchParams.get('target_project') || ''; +}; - // what: Clean up the element, but preserve id. - // why: This way we don't inherit any `ide-loading` side-effects. This - // mirrors the behavior of Vue when it mounts to an element. - const newEl = document.createElement(el.tagName); - newEl.id = el.id; - newEl.classList.add('gl--flex-center', 'gl-relative', 'gl-h-full'); +export const initGitlabWebIDE = async (el) => { + // what: Pull what we need from the element. We will replace it soon. + const { + cspNonce: nonce, + branchName: ref, + projectPath, + ideRemotePath, + filePath, + mergeRequest: mrId, + forkInfo: forkInfoJSON, + } = el.dataset; - el.replaceWith(newEl); + const rootEl = setupRootElement(el); + const forkInfo = forkInfoJSON ? JSON.parse(forkInfoJSON) : null; - // what: Trigger start on our new mounting element - await start(newEl, { - baseUrl: cleanTrailingSlash(baseUrl.href), + // See ClientOnlyConfig https://gitlab.com/gitlab-org/gitlab-web-ide/-/blob/main/packages/web-ide-types/src/config.ts#L17 + start(rootEl, { + ...getBaseConfig(), + nonce, + // Use same headers as defined in axios_utils + httpHeaders: { + [csrf.headerKey]: csrf.token, + 'X-Requested-With': 'XMLHttpRequest', + }, projectPath, - gitlabUrl, ref, - nonce, + filePath, + mrId, + mrTargetProject: getMRTargetProject(), + // note: At the time of writing this, forkInfo isn't expected by `@gitlab/web-ide`, + // but it will be soon. + forkInfo, + links: { + feedbackIssue: GITLAB_WEB_IDE_FEEDBACK_ISSUE, + userPreferences: el.dataset.userPreferencesPath, + }, + async handleStartRemote({ remoteHost, remotePath, connectionToken }) { + const confirmed = await confirmAction( + __('Are you sure you want to leave the Web IDE? All unsaved changes will be lost.'), + { + primaryBtnText: __('Start remote connection'), + cancelBtnText: __('Continue editing'), + }, + ); + + if (!confirmed) { + return; + } + + createAndSubmitForm({ + url: buildRemoteIdeURL(ideRemotePath, remoteHost, remotePath), + data: { + connection_token: connectionToken, + return_url: window.location.href, + }, + }); + }, }); }; diff --git a/app/assets/javascripts/ide/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js index 682914df9ec..7595a1cedf1 100644 --- a/app/assets/javascripts/ide/lib/diff/controller.js +++ b/app/assets/javascripts/ide/lib/diff/controller.js @@ -2,7 +2,7 @@ import { throttle } from 'lodash'; import { Range } from 'monaco-editor'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import Disposable from '../common/disposable'; -import DirtyDiffWorker from './diff_worker'; +import DirtyDiffWorker from './diff_worker?worker'; export const getDiffChangeType = (change) => { if (change.modified) { diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js index 525afcb2083..289027c3054 100644 --- a/app/assets/javascripts/ide/lib/editor_options.js +++ b/app/assets/javascripts/ide/lib/editor_options.js @@ -1,3 +1,12 @@ +import { useNewFonts } from '~/lib/utils/common_utils'; +import { getCssVariable } from '~/lib/utils/css_utils'; + +const fontOptions = {}; + +if (useNewFonts()) { + fontOptions.fontFamily = getCssVariable('--code-editor-font'); +} + export const defaultEditorOptions = { model: null, readOnly: false, @@ -9,6 +18,7 @@ export const defaultEditorOptions = { wordWrap: 'on', glyphMargin: true, automaticLayout: true, + ...fontOptions, }; export const defaultDiffOptions = { @@ -27,7 +37,6 @@ export const defaultDiffEditorOptions = { }; export const defaultModelOptions = { - endOfLine: 0, insertFinalNewline: true, trimTrailingWhitespace: false, }; diff --git a/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js new file mode 100644 index 00000000000..fbd2ce4ce69 --- /dev/null +++ b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js @@ -0,0 +1,12 @@ +import { cleanEndingSeparator } from '~/lib/utils/url_utility'; + +const getBaseUrl = () => { + const baseUrlObj = new URL(process.env.GITLAB_WEB_IDE_PUBLIC_PATH, window.location.origin); + + return cleanEndingSeparator(baseUrlObj.href); +}; + +export const getBaseConfig = () => ({ + baseUrl: getBaseUrl(), + gitlabUrl: window.gon.gitlab_url, +}); diff --git a/app/assets/javascripts/ide/lib/gitlab_web_ide/index.js b/app/assets/javascripts/ide/lib/gitlab_web_ide/index.js new file mode 100644 index 00000000000..8311e11672e --- /dev/null +++ b/app/assets/javascripts/ide/lib/gitlab_web_ide/index.js @@ -0,0 +1,2 @@ +export * from './get_base_config'; +export * from './setup_root_element'; diff --git a/app/assets/javascripts/ide/lib/gitlab_web_ide/setup_root_element.js b/app/assets/javascripts/ide/lib/gitlab_web_ide/setup_root_element.js new file mode 100644 index 00000000000..b0e06c88d26 --- /dev/null +++ b/app/assets/javascripts/ide/lib/gitlab_web_ide/setup_root_element.js @@ -0,0 +1,14 @@ +/** + * Cleans up the given element and prepares it for mounting to `@gitlab/web-ide` + * + * @param {Element} root The original root element + * @returns {Element} A new element ready to be used by `@gitlab/web-ide` + */ +export const setupRootElement = (el) => { + const newEl = document.createElement(el.tagName); + newEl.id = el.id; + newEl.classList.add('gl--flex-center', 'gl-relative', 'gl-h-full'); + el.replaceWith(newEl); + + return newEl; +}; diff --git a/app/assets/javascripts/ide/remote/index.js b/app/assets/javascripts/ide/remote/index.js new file mode 100644 index 00000000000..fb8db20c0c1 --- /dev/null +++ b/app/assets/javascripts/ide/remote/index.js @@ -0,0 +1,40 @@ +import { startRemote } from '@gitlab/web-ide'; +import { getBaseConfig, setupRootElement } from '~/ide/lib/gitlab_web_ide'; +import { isSameOriginUrl, joinPaths } from '~/lib/utils/url_utility'; + +/** + * @param {Element} rootEl + */ +export const mountRemoteIDE = async (el) => { + const { + remoteHost: remoteAuthority, + remotePath: hostPath, + cspNonce, + connectionToken, + returnUrl, + } = el.dataset; + + const rootEl = setupRootElement(el); + + const visitReturnUrl = () => { + // security: Only change `href` if of the same origin as current page + if (returnUrl && isSameOriginUrl(returnUrl)) { + window.location.href = returnUrl; + } else { + window.location.reload(); + } + }; + + startRemote(rootEl, { + ...getBaseConfig(), + nonce: cspNonce, + connectionToken, + // remoteAuthority must start with "/" + remoteAuthority: joinPaths('/', remoteAuthority), + // hostPath must start with "/" + hostPath: joinPaths('/', hostPath), + // TODO Handle error better + handleError: visitReturnUrl, + handleClose: visitReturnUrl, + }); +}; diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index 805476c71bc..1f9bc834140 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -4,7 +4,7 @@ import dismissUserCallout from '~/graphql_shared/mutations/dismiss_user_callout. import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import axios from '~/lib/utils/axios_utils'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; -import ciConfig from '~/pipeline_editor/graphql/queries/ci_config.query.graphql'; +import ciConfig from '~/ci/pipeline_editor/graphql/queries/ci_config.query.graphql'; import { query, mutate } from './gql'; export default { diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js index 91868132a5a..a510ec0847b 100644 --- a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js @@ -1,6 +1,6 @@ import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import httpStatus from '~/lib/utils/http_status'; +import httpStatus, { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; import * as terminalService from '../../../../services/terminals'; import { STARTING, STOPPING, STOPPED } from '../constants'; import * as messages from '../messages'; @@ -108,7 +108,7 @@ export const restartSession = ({ state, dispatch, rootState }) => { // We may have removed the build, in this case we'll just create a new session if ( responseStatus === httpStatus.NOT_FOUND || - responseStatus === httpStatus.UNPROCESSABLE_ENTITY + responseStatus === HTTP_STATUS_UNPROCESSABLE_ENTITY ) { dispatch('startSession'); } else { diff --git a/app/assets/javascripts/ide/stores/modules/terminal/messages.js b/app/assets/javascripts/ide/stores/modules/terminal/messages.js index ec05ca84754..fa1c7f23677 100644 --- a/app/assets/javascripts/ide/stores/modules/terminal/messages.js +++ b/app/assets/javascripts/ide/stores/modules/terminal/messages.js @@ -1,5 +1,5 @@ import { escape } from 'lodash'; -import httpStatus from '~/lib/utils/http_status'; +import httpStatus, { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; import { __, sprintf } from '~/locale'; export const UNEXPECTED_ERROR_CONFIG = __( @@ -28,7 +28,7 @@ export const ERROR_PERMISSION = __( ); export const configCheckError = (status, helpUrl) => { - if (status === httpStatus.UNPROCESSABLE_ENTITY) { + if (status === HTTP_STATUS_UNPROCESSABLE_ENTITY) { return sprintf( ERROR_CONFIG, { diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index 70efda970bf..b89d9d38a1a 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -34,5 +34,4 @@ export default () => ({ environmentsGuidanceAlertDetected: false, previewMarkdownPath: '', userPreferencesPath: '', - canUseNewWebIde: false, }); diff --git a/app/assets/javascripts/import_entities/components/group_dropdown.vue b/app/assets/javascripts/import_entities/components/group_dropdown.vue index 25d4037bbe5..f351a9a392f 100644 --- a/app/assets/javascripts/import_entities/components/group_dropdown.vue +++ b/app/assets/javascripts/import_entities/components/group_dropdown.vue @@ -1,5 +1,21 @@ <script> import { GlDropdown, GlSearchBoxByType } from '@gitlab/ui'; +import { debounce } from 'lodash'; + +import { s__ } from '~/locale'; +import { createAlert } from '~/flash'; +import searchNamespacesWhereUserCanCreateProjectsQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql'; +import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; + +const reportNamespaceLoadError = debounce( + () => + createAlert({ + message: s__('ImportProjects|Requesting namespaces failed'), + }), + DEFAULT_DEBOUNCE_AND_THROTTLE_MS, +); export default { components: { @@ -7,18 +23,32 @@ export default { GlSearchBoxByType, }, inheritAttrs: false, - props: { - namespaces: { - type: Array, - required: true, - }, - }, data() { return { searchTerm: '' }; }, + apollo: { + namespaces: { + query: searchNamespacesWhereUserCanCreateProjectsQuery, + variables() { + return { + search: this.searchTerm, + }; + }, + skip() { + const hasNotEnoughSearchCharacters = + this.searchTerm.length > 0 && this.searchTerm.length < MINIMUM_SEARCH_LENGTH; + return hasNotEnoughSearchCharacters; + }, + update(data) { + return data.currentUser.groups.nodes; + }, + error: reportNamespaceLoadError, + debounce: DEBOUNCE_DELAY, + }, + }, computed: { filteredNamespaces() { - return this.namespaces.filter((ns) => + return (this.namespaces ?? []).filter((ns) => ns.fullPath.toLowerCase().includes(this.searchTerm.toLowerCase()), ); }, diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue index 5455a034106..bd69165f0ca 100644 --- a/app/assets/javascripts/import_entities/components/import_status.vue +++ b/app/assets/javascripts/import_entities/components/import_status.vue @@ -49,7 +49,7 @@ const STATUS_MAP = { text: __('Timeout'), variant: 'danger', }, - [STATUSES.CANCELLED]: { + [STATUSES.CANCELED]: { icon: 'status-stopped', text: __('Cancelled'), variant: 'neutral', diff --git a/app/assets/javascripts/import_entities/constants.js b/app/assets/javascripts/import_entities/constants.js index c470da21765..48b7febca4b 100644 --- a/app/assets/javascripts/import_entities/constants.js +++ b/app/assets/javascripts/import_entities/constants.js @@ -9,6 +9,10 @@ export const STATUSES = { STARTED: 'started', NONE: 'none', SCHEDULING: 'scheduling', - CANCELLED: 'cancelled', + CANCELED: 'canceled', TIMEOUT: 'timeout', }; + +export const PROVIDERS = { + GITHUB: 'github', +}; diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue index 66dff77eef8..6412f26fde7 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue @@ -21,12 +21,13 @@ import { getGroupPathAvailability } from '~/rest_api'; import axios from '~/lib/utils/axios_utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { helpPagePath } from '~/helpers/help_page_helper'; +import searchNamespacesWhereUserCanCreateProjectsQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { STATUSES } from '../../constants'; import ImportStatusCell from '../../components/import_status.vue'; import importGroupsMutation from '../graphql/mutations/import_groups.mutation.graphql'; import updateImportStatusMutation from '../graphql/mutations/update_import_status.mutation.graphql'; -import availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql'; import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql'; import { NEW_NAME_FIELD, ROOT_NAMESPACE, i18n } from '../constants'; import { StatusPoller } from '../services/status_poller'; @@ -107,7 +108,12 @@ export default { return { page: this.page, filter: this.filter, perPage: this.perPage }; }, }, - availableNamespaces: availableNamespacesQuery, + availableNamespaces: { + query: searchNamespacesWhereUserCanCreateProjectsQuery, + update(data) { + return data.currentUser.groups.nodes; + }, + }, }, fields: [ @@ -158,7 +164,7 @@ export default { } return this.groups.map((group) => { - const importTarget = this.getImportTarget(group); + const importTarget = this.importTargets[group.id]; const status = this.getStatus(group); const flags = { @@ -250,10 +256,14 @@ export default { this.page = 1; }, - groupsTableData() { + groups() { const table = this.getTableRef(); const matches = new Set(); - this.groupsTableData.forEach((g, idx) => { + this.groups.forEach((g, idx) => { + if (!this.importGroups[g.id]) { + this.setDefaultImportTarget(g); + } + if (this.selectedGroupsIds.includes(g.id)) { matches.add(g.id); this.$nextTick(() => { @@ -421,7 +431,7 @@ export default { data: { exists }, } = await getGroupPathAvailability( importTarget.newName, - importTarget.targetNamespace.id, + getIdFromGraphQLId(importTarget.targetNamespace.id), { cancelToken: importTarget.cancellationToken?.token, }, @@ -444,11 +454,7 @@ export default { importTarget.validationErrors = newValidationErrors; }, VALIDATION_DEBOUNCE_TIME), - getImportTarget(group) { - if (this.importTargets[group.id]) { - return this.importTargets[group.id]; - } - + setDefaultImportTarget(group) { // If we've reached this Vue application we have at least one potential import destination const defaultTargetNamespace = // first option: namespace id was explicitly provided @@ -482,9 +488,13 @@ export default { validationErrors: [], }); - getGroupPathAvailability(importTarget.newName, importTarget.targetNamespace.id, { - cancelToken: cancellationToken.token, - }) + getGroupPathAvailability( + importTarget.newName, + getIdFromGraphQLId(importTarget.targetNamespace.id), + { + cancelToken: cancellationToken.token, + }, + ) .then(({ data: { exists, suggests: suggestions } }) => { if (!exists) return; @@ -505,7 +515,6 @@ export default { .catch(() => { // empty catch intended }); - return this.importTargets[group.id]; }, }, @@ -692,7 +701,6 @@ export default { <template #cell(importTarget)="{ item: group }"> <import-target-cell :group="group" - :available-namespaces="availableNamespaces" :group-path-regex="groupPathRegex" @update-target-namespace="updateImportTarget(group, { targetNamespace: $event })" @update-new-name="updateImportTarget(group, { newName: $event })" diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue index 4fbbd5b239c..04a90d9c20c 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue @@ -22,10 +22,6 @@ export default { type: Object, required: true, }, - availableNamespaces: { - type: Array, - required: true, - }, }, computed: { @@ -53,7 +49,6 @@ export default { #default="{ namespaces }" :text="fullPath" :disabled="!group.flags.isAvailableForImport" - :namespaces="availableNamespaces" toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" class="gl-h-7 gl-flex-grow-1" data-qa-selector="target_namespace_selector_dropdown" 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 index 36da996ea17..913a5a659b3 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js +++ b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js @@ -10,7 +10,6 @@ import typeDefs from './typedefs.graphql'; export const clientTypenames = { BulkImportSourceGroupConnection: 'ClientBulkImportSourceGroupConnection', BulkImportSourceGroup: 'ClientBulkImportSourceGroup', - AvailableNamespace: 'ClientAvailableNamespace', BulkImportPageInfo: 'ClientBulkImportPageInfo', BulkImportTarget: 'ClientBulkImportTarget', BulkImportProgress: 'ClientBulkImportProgress', @@ -110,15 +109,6 @@ export function createResolvers({ endpoints }) { }; return response; }, - - availableNamespaces: () => - axios.get(endpoints.availableNamespaces).then(({ data }) => - data.map((namespace) => ({ - __typename: clientTypenames.AvailableNamespace, - id: namespace.id, - fullPath: namespace.full_path, - })), - ), }, Mutation: { async updateImportStatus(_, { id, status: newStatus }, { client, getCacheKey }) { 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 deleted file mode 100644 index b0741dfbe5c..00000000000 --- a/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql +++ /dev/null @@ -1,6 +0,0 @@ -query availableNamespaces { - availableNamespaces @client { - id - fullPath - } -} diff --git a/app/assets/javascripts/import_entities/import_groups/index.js b/app/assets/javascripts/import_entities/import_groups/index.js index 5d7e7911f5a..494a845b1f9 100644 --- a/app/assets/javascripts/import_entities/import_groups/index.js +++ b/app/assets/javascripts/import_entities/import_groups/index.js @@ -12,7 +12,6 @@ export function mountImportGroupsApp(mountElement) { const { statusPath, - availableNamespacesPath, createBulkImportPath, jobsPath, historyPath, @@ -25,7 +24,6 @@ export function mountImportGroupsApp(mountElement) { sourceUrl, endpoints: { status: statusPath, - availableNamespaces: availableNamespacesPath, createBulkImport: createBulkImportPath, }, }), diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue index 97a7ed4bf55..63a36f1a79f 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue @@ -37,6 +37,11 @@ export default { required: false, default: false, }, + cancelable: { + type: Boolean, + required: false, + default: false, + }, optionalStages: { type: Array, required: false, @@ -58,9 +63,8 @@ export default { }, computed: { - ...mapState(['filter', 'repositories', 'namespaces', 'defaultTargetNamespace', 'pageInfo']), + ...mapState(['filter', 'repositories', 'defaultTargetNamespace', 'pageInfo', 'isLoadingRepos']), ...mapGetters([ - 'isLoading', 'isImportingAnyRepo', 'importingRepoCount', 'hasImportableRepos', @@ -98,7 +102,6 @@ export default { }, mounted() { - this.fetchNamespaces(); this.fetchJobs(); if (!this.paginatable) { @@ -115,7 +118,6 @@ export default { ...mapActions([ 'fetchRepos', 'fetchJobs', - 'fetchNamespaces', 'stopJobsPolling', 'clearJobsEtagPoll', 'setFilter', @@ -196,22 +198,22 @@ export default { <provider-repo-table-row :key="repo.importSource.providerLink" :repo="repo" - :available-namespaces="namespaces" :user-namespace="defaultTargetNamespace" :optional-stages="optionalStagesSelection" + :cancelable="cancelable" /> </template> </tbody> </table> </div> <gl-intersection-observer - v-if="paginatable" + v-if="paginatable && pageInfo.hasNextPage" :key="pagePaginationStateKey" @appear="fetchRepos" /> - <gl-loading-icon v-if="isLoading" class="gl-mt-7" size="lg" /> + <gl-loading-icon v-if="isLoadingRepos" class="gl-mt-7" size="lg" /> - <div v-if="!isLoading && repositories.length === 0" class="gl-text-center"> + <div v-if="!isLoadingRepos && repositories.length === 0" class="gl-text-center"> <strong>{{ emptyStateText }}</strong> </div> </div> diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue index 458e0fb1cb1..b8faf349375 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue @@ -8,13 +8,15 @@ import { GlDropdownItem, GlDropdownDivider, GlDropdownSectionHeader, + GlTooltip, } from '@gitlab/ui'; import { mapState, mapGetters, mapActions } from 'vuex'; import { __ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; import ImportGroupDropdown from '../../components/group_dropdown.vue'; import ImportStatus from '../../components/import_status.vue'; import { STATUSES } from '../../constants'; -import { isProjectImportable, isIncompatible, getImportStatus } from '../utils'; +import { isProjectImportable, isImporting, isIncompatible, getImportStatus } from '../utils'; export default { name: 'ProviderRepoTableRow', @@ -29,6 +31,7 @@ export default { GlIcon, GlBadge, GlLink, + GlTooltip, }, props: { repo: { @@ -39,14 +42,15 @@ export default { type: String, required: true, }, - availableNamespaces: { - type: Array, - required: true, - }, optionalStages: { type: Object, required: true, }, + cancelable: { + type: Boolean, + required: false, + default: false, + }, }, computed: { @@ -73,6 +77,14 @@ export default { return getImportStatus(this.repo); }, + isImporting() { + return isImporting(this.repo); + }, + + isCancelable() { + return this.cancelable && this.isImporting && this.importStatus !== STATUSES.SCHEDULING; + }, + stats() { return this.repo.importedProject?.stats; }, @@ -96,7 +108,7 @@ export default { }, methods: { - ...mapActions(['fetchImport', 'setImportTarget']), + ...mapActions(['fetchImport', 'cancelImport', 'setImportTarget']), updateImportTarget(changedValues) { this.setImportTarget({ repoId: this.repo.importSource.id, @@ -104,6 +116,8 @@ export default { }); }, }, + + helpUrl: helpPagePath('/user/project/import/github.md'), }; </script> @@ -127,11 +141,7 @@ export default { <template v-if="repo.importSource.target">{{ repo.importSource.target }}</template> <template v-else-if="isImportNotStarted"> <div class="import-entities-target-select gl-display-flex gl-align-items-stretch gl-w-full"> - <import-group-dropdown - #default="{ namespaces }" - :text="importTarget.targetNamespace" - :namespaces="availableNamespaces" - > + <import-group-dropdown #default="{ namespaces }" :text="importTarget.targetNamespace"> <template v-if="namespaces.length"> <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header> <gl-dropdown-item @@ -168,6 +178,26 @@ export default { <import-status :status="importStatus" :stats="stats" /> </td> <td data-testid="actions" class="gl-vertical-align-top gl-pt-4"> + <gl-tooltip :target="() => $refs.cancelButton.$el"> + <div class="gl-text-left"> + <p class="gl-mb-5 gl-font-weight-bold">{{ s__('ImportProjects|Cancel import') }}</p> + {{ + s__( + 'ImportProjects|Imported files will be kept. You can import this repository again later.', + ) + }} + <gl-link :href="$options.helpUrl" target="_blank">{{ __('Learn more.') }}</gl-link> + </div> + </gl-tooltip> + <gl-button + v-show="isCancelable" + ref="cancelButton" + variant="danger" + category="secondary" + icon="cancel" + :aria-label="__('Cancel')" + @click="cancelImport({ repoId: repo.importSource.id })" + /> <gl-button v-if="isFinished" class="btn btn-default" diff --git a/app/assets/javascripts/import_entities/import_projects/index.js b/app/assets/javascripts/import_entities/import_projects/index.js index df26d6ac4f6..197fb03af2c 100644 --- a/app/assets/javascripts/import_entities/import_projects/index.js +++ b/app/assets/javascripts/import_entities/import_projects/index.js @@ -1,10 +1,14 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { parseBoolean } from '~/lib/utils/common_utils'; import Translate from '~/vue_shared/translate'; +import createDefaultClient from '~/lib/graphql'; import ImportProjectsTable from './components/import_projects_table.vue'; + import createStore from './store'; Vue.use(Translate); +Vue.use(VueApollo); export function initStoreFromElement(element) { const { @@ -15,7 +19,7 @@ export function initStoreFromElement(element) { reposPath, jobsPath, importPath, - namespacesPath, + cancelPath, defaultTargetNamespace, paginatable, } = element.dataset; @@ -31,7 +35,7 @@ export function initStoreFromElement(element) { reposPath, jobsPath, importPath, - namespacesPath, + cancelPath, }, hasPagination: parseBoolean(paginatable), }); @@ -43,9 +47,16 @@ export function initPropsFromElement(element) { filterable: parseBoolean(element.dataset.filterable), paginatable: parseBoolean(element.dataset.paginatable), optionalStages: JSON.parse(element.dataset.optionalStages), + cancelable: Boolean(element.dataset.cancelPath), }; } +const defaultClient = createDefaultClient(); + +const apolloProvider = new VueApollo({ + defaultClient, +}); + export default function mountImportProjectsTable(mountElement) { if (!mountElement) return undefined; @@ -55,6 +66,7 @@ export default function mountImportProjectsTable(mountElement) { return new Vue({ el: mountElement, store, + apolloProvider, render(createElement) { return createElement(ImportProjectsTable, { props }); }, diff --git a/app/assets/javascripts/import_entities/import_projects/store/actions.js b/app/assets/javascripts/import_entities/import_projects/store/actions.js index a30c14f9d28..e0db585eb3e 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/actions.js +++ b/app/assets/javascripts/import_entities/import_projects/store/actions.js @@ -1,20 +1,22 @@ import Visibility from 'visibilityjs'; +import _ from 'lodash'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_TOO_MANY_REQUESTS } from '~/lib/utils/http_status'; import Poll from '~/lib/utils/poll'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { visitUrl, objectToQuery } from '~/lib/utils/url_utility'; import { s__, sprintf } from '~/locale'; import { isProjectImportable } from '../utils'; +import { PROVIDERS } from '../../constants'; import * as types from './mutation_types'; let eTagPoll; const hasRedirectInError = (e) => e?.response?.data?.error?.redirect; const redirectToUrlInError = (e) => visitUrl(e.response.data.error.redirect); -const tooManyRequests = (e) => e.response.status === httpStatusCodes.TOO_MANY_REQUESTS; +const tooManyRequests = (e) => e.response.status === HTTP_STATUS_TOO_MANY_REQUESTS; const pathWithParams = ({ path, ...params }) => { const filteredParams = Object.fromEntries( Object.entries(params).filter(([, value]) => value !== ''), @@ -22,6 +24,24 @@ const pathWithParams = ({ path, ...params }) => { const queryString = objectToQuery(filteredParams); return queryString ? `${path}?${queryString}` : path; }; +const commitPaginationData = ({ state, commit, data }) => { + const cursorsGitHubResponse = !_.isEmpty(data.pageInfo || {}); + + if (state.provider === PROVIDERS.GITHUB && cursorsGitHubResponse) { + commit(types.SET_PAGE_CURSORS, data.pageInfo); + } else { + const nextPage = state.pageInfo.page + 1; + commit(types.SET_PAGE, nextPage); + } +}; +const paginationParams = ({ state }) => { + if (state.provider === PROVIDERS.GITHUB && state.pageInfo.endCursor) { + return { after: state.pageInfo.endCursor }; + } + + const nextPage = state.pageInfo.page + 1; + return { page: nextPage === 1 ? '' : nextPage.toString() }; +}; const isRequired = () => { // eslint-disable-next-line @gitlab/require-i18n-strings @@ -55,7 +75,6 @@ const importAll = ({ state, dispatch }, config = {}) => { }; const fetchReposFactory = ({ reposPath = isRequired() }) => ({ state, commit }) => { - const nextPage = state.pageInfo.page + 1; commit(types.REQUEST_REPOS); const { provider, filter } = state; @@ -65,12 +84,13 @@ const fetchReposFactory = ({ reposPath = isRequired() }) => ({ state, commit }) pathWithParams({ path: reposPath, filter: filter ?? '', - page: nextPage === 1 ? '' : nextPage.toString(), + ...paginationParams({ state }), }), ) .then(({ data }) => { - commit(types.SET_PAGE, nextPage); - commit(types.RECEIVE_REPOS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })); + const camelData = convertObjectPropsToCamelCase(data, { deep: true }); + commitPaginationData({ state, commit, data: camelData }); + commit(types.RECEIVE_REPOS_SUCCESS, camelData); }) .catch((e) => { if (hasRedirectInError(e)) { @@ -139,6 +159,42 @@ const fetchImportFactory = (importPath = isRequired()) => ( }); }; +export const cancelImportFactory = (cancelImportPath) => ({ state, commit }, { repoId }) => { + const existingRepo = state.repositories.find((r) => r.importSource.id === repoId); + + if (!existingRepo?.importedProject) { + throw new Error(`Attempting to cancel project which is not started: ${repoId}`); + } + + const { id } = existingRepo.importedProject; + + return axios + .post(cancelImportPath, { + project_id: id, + }) + .then(() => { + commit(types.CANCEL_IMPORT_SUCCESS, { + repoId, + }); + }) + .catch((e) => { + const serverErrorMessage = e?.response?.data?.errors; + const flashMessage = serverErrorMessage + ? sprintf( + s__('ImportProjects|Cancelling project import failed: %{reason}'), + { + reason: serverErrorMessage, + }, + false, + ) + : s__('ImportProjects|Cancelling project import failed'); + + createAlert({ + message: flashMessage, + }); + }); +}; + export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, dispatch }) => { if (eTagPoll) { stopJobsPolling(); @@ -176,22 +232,6 @@ export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, d }); }; -const fetchNamespacesFactory = (namespacesPath = isRequired()) => ({ commit }) => { - commit(types.REQUEST_NAMESPACES); - axios - .get(namespacesPath) - .then(({ data }) => - commit(types.RECEIVE_NAMESPACES_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })), - ) - .catch(() => { - createAlert({ - message: s__('ImportProjects|Requesting namespaces failed'), - }); - - commit(types.RECEIVE_NAMESPACES_ERROR); - }); -}; - const setFilter = ({ commit, dispatch }, filter) => { commit(types.SET_FILTER, filter); @@ -207,6 +247,6 @@ export default ({ endpoints = isRequired() }) => ({ importAll, fetchRepos: fetchReposFactory({ reposPath: endpoints.reposPath }), fetchImport: fetchImportFactory(endpoints.importPath), + cancelImport: cancelImportFactory(endpoints.cancelPath), fetchJobs: fetchJobsFactory(endpoints.jobsPath), - fetchNamespaces: fetchNamespacesFactory(endpoints.namespacesPath), }); diff --git a/app/assets/javascripts/import_entities/import_projects/store/getters.js b/app/assets/javascripts/import_entities/import_projects/store/getters.js index ef01a67ec94..31ddffd4eb4 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/getters.js +++ b/app/assets/javascripts/import_entities/import_projects/store/getters.js @@ -1,7 +1,5 @@ import { isProjectImportable, isIncompatible, isImporting } from '../utils'; -export const isLoading = (state) => state.isLoadingRepos || state.isLoadingNamespaces; - export const importingRepoCount = (state) => state.repositories.filter(isImporting).length; export const isImportingAnyRepo = (state) => state.repositories.some(isImporting); diff --git a/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js b/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js index 6adf5e59cff..74832a03ac1 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js +++ b/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js @@ -2,14 +2,12 @@ export const REQUEST_REPOS = 'REQUEST_REPOS'; export const RECEIVE_REPOS_SUCCESS = 'RECEIVE_REPOS_SUCCESS'; export const RECEIVE_REPOS_ERROR = 'RECEIVE_REPOS_ERROR'; -export const REQUEST_NAMESPACES = 'REQUEST_NAMESPACES'; -export const RECEIVE_NAMESPACES_SUCCESS = 'RECEIVE_NAMESPACES_SUCCESS'; -export const RECEIVE_NAMESPACES_ERROR = 'RECEIVE_NAMESPACES_ERROR'; - export const REQUEST_IMPORT = 'REQUEST_IMPORT'; export const RECEIVE_IMPORT_SUCCESS = 'RECEIVE_IMPORT_SUCCESS'; export const RECEIVE_IMPORT_ERROR = 'RECEIVE_IMPORT_ERROR'; +export const CANCEL_IMPORT_SUCCESS = 'CANCEL_IMPORT_SUCCESS'; + export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS'; export const SET_FILTER = 'SET_FILTER'; @@ -18,4 +16,4 @@ export const SET_IMPORT_TARGET = 'SET_IMPORT_TARGET'; export const SET_PAGE = 'SET_PAGE'; -export const SET_PAGE_INFO = 'SET_PAGE_INFO'; +export const SET_PAGE_CURSORS = 'SET_PAGE_CURSORS'; diff --git a/app/assets/javascripts/import_entities/import_projects/store/mutations.js b/app/assets/javascripts/import_entities/import_projects/store/mutations.js index 163a19976de..8b2e0364d7a 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/mutations.js +++ b/app/assets/javascripts/import_entities/import_projects/store/mutations.js @@ -36,7 +36,12 @@ export default { [types.SET_FILTER](state, filter) { state.filter = filter; state.repositories = []; - state.pageInfo.page = 0; + state.pageInfo = { + page: 0, + startCursor: null, + endCursor: null, + hasNextPage: true, + }; }, [types.REQUEST_REPOS](state) { @@ -51,7 +56,9 @@ export default { // https://gitlab.com/gitlab-org/gitlab/-/issues/27370#note_379034091 const newImportedProjects = processLegacyEntries({ - newRepositories: repositories.importedProjects, + newRepositories: repositories.importedProjects.filter( + (p) => p.importStatus !== STATUSES.CANCELED, + ), existingRepositories: state.repositories, factory: makeNewImportedProject, }); @@ -122,17 +129,9 @@ export default { }); }, - [types.REQUEST_NAMESPACES](state) { - state.isLoadingNamespaces = true; - }, - - [types.RECEIVE_NAMESPACES_SUCCESS](state, namespaces) { - state.isLoadingNamespaces = false; - state.namespaces = namespaces; - }, - - [types.RECEIVE_NAMESPACES_ERROR](state) { - state.isLoadingNamespaces = false; + [types.CANCEL_IMPORT_SUCCESS](state, { repoId }) { + const existingRepo = state.repositories.find((r) => r.importSource.id === repoId); + existingRepo.importedProject.importStatus = STATUSES.CANCELED; }, [types.SET_IMPORT_TARGET](state, { repoId, importTarget }) { @@ -151,4 +150,9 @@ export default { [types.SET_PAGE](state, page) { state.pageInfo.page = page; }, + + [types.SET_PAGE_CURSORS](state, pageInfo) { + const { startCursor, endCursor, hasNextPage } = pageInfo; + state.pageInfo = { ...state.pageInfo, startCursor, endCursor, hasNextPage }; + }, }; diff --git a/app/assets/javascripts/import_entities/import_projects/store/state.js b/app/assets/javascripts/import_entities/import_projects/store/state.js index ecd93561d52..c384848f0a0 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/state.js +++ b/app/assets/javascripts/import_entities/import_projects/store/state.js @@ -1,13 +1,14 @@ export default () => ({ provider: '', repositories: [], - namespaces: [], customImportTargets: {}, isLoadingRepos: false, - isLoadingNamespaces: false, ciCdOnly: false, filter: '', pageInfo: { page: 0, + startCursor: null, + endCursor: null, + hasNextPage: true, }, }); diff --git a/app/assets/javascripts/import_entities/import_projects/utils.js b/app/assets/javascripts/import_entities/import_projects/utils.js index 38bd529321a..c4c9e544c1e 100644 --- a/app/assets/javascripts/import_entities/import_projects/utils.js +++ b/app/assets/javascripts/import_entities/import_projects/utils.js @@ -9,7 +9,10 @@ export function getImportStatus(project) { } export function isProjectImportable(project) { - return !isIncompatible(project) && getImportStatus(project) === STATUSES.NONE; + return ( + !isIncompatible(project) && + [STATUSES.NONE, STATUSES.CANCELED].includes(getImportStatus(project)) + ); } export function isImporting(repo) { diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index dbd2225167a..14ab7b2dc1e 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -14,7 +14,7 @@ import { import { isValidSlaDueAt } from 'ee_else_ce/vue_shared/components/incidents/utils'; import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility'; import { s__, n__ } from '~/locale'; -import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants'; +import { INCIDENT_SEVERITY } from '~/sidebar/constants'; import SeverityToken from '~/sidebar/components/severity/severity.vue'; import Tracking from '~/tracking'; import { diff --git a/app/assets/javascripts/incidents_settings/incidents_settings_service.js b/app/assets/javascripts/incidents_settings/incidents_settings_service.js index 93baa54956a..d3850114350 100644 --- a/app/assets/javascripts/incidents_settings/incidents_settings_service.js +++ b/app/assets/javascripts/incidents_settings/incidents_settings_service.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; import { ERROR_MSG } from './constants'; @@ -22,7 +22,7 @@ export default class IncidentsSettingsService { .catch(({ response }) => { const message = response?.data?.message || ''; - createFlash({ + createAlert({ message: `${ERROR_MSG} ${message}`, }); }); diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue index fe687ea9767..904e5639cac 100644 --- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue +++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue @@ -1,14 +1,8 @@ <script> -import { - GlFormGroup, - GlFormCheckbox, - GlFormInput, - GlFormSelect, - GlFormTextarea, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; +import { GlFormGroup, GlFormCheckbox, GlFormInput, GlFormSelect, GlFormTextarea } from '@gitlab/ui'; import { capitalize, lowerCase, isEmpty } from 'lodash'; import { mapGetters } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; export default { name: 'DynamicField', @@ -80,7 +74,7 @@ export default { }; }, computed: { - ...mapGetters(['isInheriting']), + ...mapGetters(['isInheriting', 'propsSource']), isCheckbox() { return this.type === 'checkbox'; }, @@ -122,11 +116,18 @@ export default { name: this.fieldName, state: this.valid, readonly: this.isInheriting, + disabled: this.isDisabled, }; }, valid() { return !this.required || !isEmpty(this.model) || this.isNonEmptyPassword || !this.isValidated; }, + isInheritingOrDisabled() { + return this.isInheriting || this.isDisabled; + }, + isDisabled() { + return !this.propsSource.editable; + }, }, created() { if (this.isNonEmptyPassword) { @@ -149,7 +150,7 @@ export default { <template v-if="isCheckbox"> <input :name="fieldName" type="hidden" :value="model || false" /> - <gl-form-checkbox :id="fieldId" v-model="model" :disabled="isInheriting"> + <gl-form-checkbox :id="fieldId" v-model="model" :disabled="isInheritingOrDisabled"> {{ checkboxLabel || humanizedTitle }} <template #help> <span v-safe-html="help"></span> @@ -158,7 +159,12 @@ export default { </template> <template v-else-if="isSelect"> <input type="hidden" :name="fieldName" :value="model" /> - <gl-form-select :id="fieldId" v-model="model" :options="options" :disabled="isInheriting" /> + <gl-form-select + :id="fieldId" + v-model="model" + :options="options" + :disabled="isInheritingOrDisabled" + /> </template> <gl-form-textarea v-else-if="isTextarea" diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index 4bf2b8d4468..d86e6326f64 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -1,22 +1,15 @@ <script> -import { - GlAlert, - GlBadge, - GlButton, - GlModalDirective, - GlSafeHtmlDirective as SafeHtml, - GlForm, -} from '@gitlab/ui'; +import { GlAlert, GlBadge, GlButton, GlForm } from '@gitlab/ui'; import axios from 'axios'; import * as Sentry from '@sentry/browser'; import { mapState, mapActions, mapGetters } from 'vuex'; import { s__ } from '~/locale'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE, I18N_DEFAULT_ERROR_MESSAGE, I18N_SUCCESSFUL_CONNECTION_MESSAGE, INTEGRATION_FORM_TYPE_SLACK, - integrationLevels, integrationFormSectionComponents, billingPlanNames, } from '~/integrations/constants'; @@ -25,11 +18,10 @@ import csrf from '~/lib/utils/csrf'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { testIntegrationSettings } from '../api'; import ActiveCheckbox from './active_checkbox.vue'; -import ConfirmationModal from './confirmation_modal.vue'; import DynamicField from './dynamic_field.vue'; import OverrideDropdown from './override_dropdown.vue'; -import ResetConfirmationModal from './reset_confirmation_modal.vue'; import TriggerFields from './trigger_fields.vue'; +import IntegrationFormActions from './integration_form_actions.vue'; export default { name: 'IntegrationForm', @@ -38,8 +30,7 @@ export default { ActiveCheckbox, TriggerFields, DynamicField, - ConfirmationModal, - ResetConfirmationModal, + IntegrationFormActions, IntegrationSectionConfiguration: () => import( /* webpackChunkName: 'integrationSectionConfiguration' */ '~/integrations/edit/components/sections/configuration.vue' @@ -66,7 +57,6 @@ export default { GlForm, }, directives: { - GlModal: GlModalDirective, SafeHtml, }, mixins: [glFeatureFlagsMixin()], @@ -78,10 +68,10 @@ export default { data() { return { integrationActive: false, - isTesting: false, + isValidated: false, isSaving: false, + isTesting: false, isResetting: false, - isValidated: false, }; }, computed: { @@ -90,21 +80,6 @@ export default { isEditable() { return this.propsSource.editable; }, - isInstanceOrGroupLevel() { - return ( - this.customState.integrationLevel === integrationLevels.INSTANCE || - this.customState.integrationLevel === integrationLevels.GROUP - ); - }, - showResetButton() { - return this.isInstanceOrGroupLevel && this.propsSource.resetPath; - }, - showTestButton() { - return this.propsSource.canTest; - }, - disableButtons() { - return Boolean(this.isSaving || this.isResetting || this.isTesting); - }, hasSections() { if (this.hasSlackNotificationsDisabled) { return false; @@ -134,6 +109,14 @@ export default { } return !this.hasSections && this.helpHtml; }, + shouldUpgradeSlack() { + return ( + this.isSlackIntegration && + this.glFeatures.integrationSlackAppNotifications && + this.customState.shouldUpgradeSlack && + (this.hasFieldsWithoutSection || this.hasSections) + ); + }, }, methods: { ...mapActions(['setOverride', 'requestJiraIssueTypes']), @@ -148,7 +131,6 @@ export default { }, onSaveClick() { this.isSaving = true; - if (this.integrationActive && !this.form().checkValidity()) { this.isSaving = false; this.setIsValidated(); @@ -194,7 +176,6 @@ export default { }, onResetClick() { this.isResetting = true; - return axios .post(this.propsSource.resetPath) .then(() => { @@ -227,7 +208,10 @@ export default { billingPlanNames, slackUpgradeInfo: { title: s__( - `SlackIntegration|Notifications only work if you're on the latest version of the GitLab for Slack app`, + `SlackIntegration|Update to the latest version of GitLab for Slack to get notifications`, + ), + text: s__( + `SlackIntegration|Update to the latest version to receive notifications from GitLab.`, ), btnText: s__('SlackIntegration|Update to the latest version'), }, @@ -284,16 +268,18 @@ export default { </div> </section> + <div v-if="shouldUpgradeSlack" class="gl-border-t"> + <gl-alert + :dismissible="false" + :title="$options.slackUpgradeInfo.title" + :primary-button-link="customState.upgradeSlackUrl" + :primary-button-text="$options.slackUpgradeInfo.btnText" + class="gl-mb-8 gl-mt-5" + >{{ $options.slackUpgradeInfo.text }}</gl-alert + > + </div> + <template v-if="hasSections"> - <div v-if="customState.shouldUpgradeSlack && isSlackIntegration" class="gl-border-t"> - <gl-alert - :title="$options.slackUpgradeInfo.title" - variant="warning" - :primary-button-link="customState.upgradeSlackUrl" - :primary-button-text="$options.slackUpgradeInfo.btnText" - class="gl-mb-8 gl-mt-5" - /> - </div> <div v-for="(section, index) in customState.sections" :key="section.type" @@ -344,71 +330,16 @@ export default { </div> </section> - <section v-if="isEditable" :class="!hasSections && 'gl-lg-display-flex gl-justify-content-end'"> - <div :class="!hasSections && 'gl-flex-basis-two-thirds'"> - <div - class="footer-block row-content-block gl-lg-display-flex gl-justify-content-space-between" - > - <div> - <template v-if="isInstanceOrGroupLevel"> - <gl-button - v-gl-modal.confirmSaveIntegration - category="primary" - variant="confirm" - :loading="isSaving" - :disabled="disableButtons" - data-testid="save-button-instance-group" - data-qa-selector="save_changes_button" - > - {{ __('Save changes') }} - </gl-button> - <confirmation-modal @submit="onSaveClick" /> - </template> - <gl-button - v-else - category="primary" - variant="confirm" - type="submit" - :loading="isSaving" - :disabled="disableButtons" - data-testid="save-button" - data-qa-selector="save_changes_button" - @click.prevent="onSaveClick" - > - {{ __('Save changes') }} - </gl-button> - - <gl-button - v-if="showTestButton" - category="secondary" - variant="confirm" - :loading="isTesting" - :disabled="disableButtons" - data-testid="test-button" - @click.prevent="onTestClick" - > - {{ __('Test settings') }} - </gl-button> - - <gl-button :href="propsSource.cancelPath">{{ __('Cancel') }}</gl-button> - </div> - - <template v-if="showResetButton"> - <gl-button - v-gl-modal.confirmResetIntegration - category="tertiary" - variant="danger" - :loading="isResetting" - :disabled="disableButtons" - data-testid="reset-button" - > - {{ __('Reset') }} - </gl-button> - - <reset-confirmation-modal @reset="onResetClick" /> - </template> - </div> - </div> - </section> + <integration-form-actions + v-if="isEditable" + :has-sections="hasSections" + :class="{ 'gl-lg-display-flex gl-justify-content-end': !hasSections }" + :is-saving="isSaving" + :is-testing="isTesting" + :is-resetting="isResetting" + @save="onSaveClick" + @test="onTestClick" + @reset="onResetClick" + /> </gl-form> </template> diff --git a/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue b/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue new file mode 100644 index 00000000000..e5ad5149cf7 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue @@ -0,0 +1,143 @@ +<script> +import { GlButton, GlModalDirective } from '@gitlab/ui'; +import { mapState, mapGetters } from 'vuex'; +import { integrationLevels } from '~/integrations/constants'; +import ConfirmationModal from './confirmation_modal.vue'; +import ResetConfirmationModal from './reset_confirmation_modal.vue'; + +export default { + name: 'IntegrationFormActions', + components: { + GlButton, + ConfirmationModal, + ResetConfirmationModal, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + hasSections: { + type: Boolean, + required: true, + }, + isSaving: { + type: Boolean, + required: false, + default: false, + }, + isTesting: { + type: Boolean, + required: false, + default: false, + }, + isResetting: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + ...mapGetters(['propsSource']), + ...mapState(['customState']), + isInstanceOrGroupLevel() { + return ( + this.customState.integrationLevel === integrationLevels.INSTANCE || + this.customState.integrationLevel === integrationLevels.GROUP + ); + }, + showResetButton() { + return this.isInstanceOrGroupLevel && this.propsSource.resetPath; + }, + showTestButton() { + return this.propsSource.canTest; + }, + disableButtons() { + return Boolean(this.isSaving || this.isResetting || this.isTesting); + }, + }, + methods: { + onSaveClick() { + this.$emit('save'); + }, + onTestClick() { + this.$emit('test'); + }, + onResetClick() { + this.$emit('reset'); + }, + }, +}; +</script> +<template> + <section> + <div :class="{ 'gl-flex-basis-two-thirds': !hasSections }"> + <div + class="footer-block row-content-block gl-lg-display-flex gl-justify-content-space-between" + > + <div> + <template v-if="isInstanceOrGroupLevel"> + <gl-button + v-gl-modal.confirmSaveIntegration + category="primary" + variant="confirm" + :loading="isSaving" + :disabled="disableButtons" + data-testid="save-button" + data-qa-selector="save_changes_button" + > + {{ __('Save changes') }} + </gl-button> + <confirmation-modal @submit="onSaveClick" /> + </template> + <gl-button + v-else + category="primary" + variant="confirm" + type="submit" + :loading="isSaving" + :disabled="disableButtons" + data-testid="save-button" + data-qa-selector="save_changes_button" + @click.prevent="onSaveClick" + > + {{ __('Save changes') }} + </gl-button> + + <gl-button + v-if="showTestButton" + category="secondary" + variant="confirm" + :loading="isTesting" + :disabled="disableButtons" + data-testid="test-button" + @click.prevent="onTestClick" + > + {{ __('Test settings') }} + </gl-button> + + <gl-button + :href="propsSource.cancelPath" + data-testid="cancel-button" + :disabled="disableButtons" + >{{ __('Cancel') }}</gl-button + > + </div> + + <template v-if="showResetButton"> + <gl-button + v-gl-modal.confirmResetIntegration + category="tertiary" + variant="danger" + :loading="isResetting" + :disabled="disableButtons" + data-testid="reset-button" + > + {{ __('Reset') }} + </gl-button> + + <reset-confirmation-modal @reset="onResetClick" /> + </template> + </div> + </div> + </section> +</template> diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js index f15ad5e052e..b53bcd50f16 100644 --- a/app/assets/javascripts/integrations/edit/index.js +++ b/app/assets/javascripts/integrations/edit/index.js @@ -108,6 +108,7 @@ export default function initIntegrationSettingsForm() { const initialState = { defaultState: null, customState: customSettingsProps, + editable: customSettingsProps.editable && !customSettingsProps.shouldUpgradeSlack, }; if (defaultSettingsEl) { initialState.defaultState = Object.freeze(parseDatasetToProps(defaultSettingsEl.dataset)); diff --git a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue index 31b7fd4cc42..b4e9a3a1559 100644 --- a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue @@ -5,6 +5,10 @@ import { importProjectMembers } from '~/api/projects_api'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { s__, __, sprintf } from '~/locale'; import eventHub from '../event_hub'; +import { + displaySuccessfulInvitationAlert, + reloadOnInvitationSuccess, +} from '../utils/trigger_successful_invite_alert'; import ProjectSelect from './project_select.vue'; export default { @@ -24,6 +28,11 @@ export default { type: String, required: true, }, + reloadPageOnSubmit: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -59,6 +68,10 @@ export default { }, }, mounted() { + if (this.reloadPageOnSubmit) { + displaySuccessfulInvitationAlert(); + } + eventHub.$on('openProjectMembersModal', () => { this.openModal(); }); @@ -74,16 +87,22 @@ export default { submitImport() { this.isLoading = true; return importProjectMembers(this.projectId, this.projectToBeImported.id) - .then(this.showToastMessage) + .then(this.onInviteSuccess) .catch(this.showErrorAlert) .finally(() => { this.isLoading = false; this.projectToBeImported = {}; }); }, + onInviteSuccess() { + if (this.reloadPageOnSubmit) { + reloadOnInvitationSuccess(); + } else { + this.showToastMessage(); + } + }, showToastMessage() { this.$toast.show(this.$options.i18n.successMessage, this.$options.toastOptions); - this.closeModal(); }, showErrorAlert() { diff --git a/app/assets/javascripts/invite_members/components/invite_group_notification.vue b/app/assets/javascripts/invite_members/components/invite_group_notification.vue new file mode 100644 index 00000000000..767675cc64c --- /dev/null +++ b/app/assets/javascripts/invite_members/components/invite_group_notification.vue @@ -0,0 +1,37 @@ +<script> +import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import { GROUP_MODAL_ALERT_BODY } from '../constants'; + +const SHARE_GROUP_LINK = + 'https://docs.gitlab.com/ee/user/group/manage.html#share-a-group-with-another-group'; + +export default { + SHARE_GROUP_LINK, + name: 'InviteGroupNotification', + components: { GlAlert, GlSprintf, GlLink }, + inject: ['freeUsersLimit'], + props: { + name: { + type: String, + required: true, + }, + }, + i18n: { + body: GROUP_MODAL_ALERT_BODY, + }, +}; +</script> + +<template> + <gl-alert variant="warning" :dismissible="false"> + <gl-sprintf :message="$options.i18n.body"> + <template #link="{ content }"> + <gl-link :href="$options.SHARE_GROUP_LINK" target="_blank" class="gl-label-link">{{ + content + }}</gl-link> + </template> + + <template #count>{{ freeUsersLimit }}</template> + </gl-sprintf> + </gl-alert> +</template> diff --git a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue index 2ad4bb1a11a..3be3b9df747 100644 --- a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue @@ -6,13 +6,19 @@ import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_b import { GROUP_FILTERS, GROUP_MODAL_LABELS } from '../constants'; import eventHub from '../event_hub'; import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message'; +import { + displaySuccessfulInvitationAlert, + reloadOnInvitationSuccess, +} from '../utils/trigger_successful_invite_alert'; import GroupSelect from './group_select.vue'; +import InviteGroupNotification from './invite_group_notification.vue'; export default { name: 'InviteMembersModal', components: { GroupSelect, InviteModalBase, + InviteGroupNotification, }, props: { id: { @@ -31,6 +37,10 @@ export default { type: String, required: true, }, + fullPath: { + type: String, + required: true, + }, accessLevels: { type: Object, required: true, @@ -57,6 +67,15 @@ export default { type: Array, required: true, }, + freeUserCapEnabled: { + type: Boolean, + required: true, + }, + reloadPageOnSubmit: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -85,6 +104,10 @@ export default { }, }, mounted() { + if (this.reloadPageOnSubmit) { + displaySuccessfulInvitationAlert(); + } + eventHub.$on('openGroupModal', () => { this.openModal(); }); @@ -114,7 +137,7 @@ export default { expires_at: expiresAt, }) .then(() => { - this.showSuccessMessage(); + this.onInviteSuccess(); }) .catch((e) => { this.showInvalidFeedbackMessage(e); @@ -128,6 +151,13 @@ export default { this.isLoading = false; this.groupToBeSharedWith = {}; }, + onInviteSuccess() { + if (this.reloadPageOnSubmit) { + reloadOnInvitationSuccess(); + } else { + this.showSuccessMessage(); + } + }, showSuccessMessage() { this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); this.closeModal(); @@ -155,9 +185,14 @@ export default { :root-group-id="rootId" :invalid-feedback-message="invalidFeedbackMessage" :is-loading="isLoading" + :full-path="fullPath" @reset="resetFields" @submit="sendInvite" > + <template #alert> + <invite-group-notification v-if="freeUserCapEnabled" :name="name" /> + </template> + <template #select> <group-select v-model="groupToBeSharedWith" diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue index f61e822bf7e..fbb547c28ff 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -29,6 +29,10 @@ import eventHub from '../event_hub'; import { responseFromSuccess } from '../utils/response_message_parser'; import { memberName } from '../utils/member_utils'; import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message'; +import { + displaySuccessfulInvitationAlert, + reloadOnInvitationSuccess, +} from '../utils/trigger_successful_invite_alert'; import ModalConfetti from './confetti.vue'; import MembersTokenSelect from './members_token_select.vue'; import UserLimitNotification from './user_limit_notification.vue'; @@ -98,11 +102,20 @@ export default { type: Array, required: true, }, + fullPath: { + type: String, + required: true, + }, usersLimitDataset: { type: Object, required: false, default: () => ({}), }, + reloadPageOnSubmit: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -119,7 +132,7 @@ export default { selectedAccessLevel: undefined, errorsLimit: 2, isErrorsSectionExpanded: false, - emptyInvitesError: false, + shouldShowEmptyInvitesAlert: false, }; }, computed: { @@ -204,12 +217,15 @@ export default { count: this.errorsExpanded.length, }); }, + formGroupDescription() { + return this.invalidFeedbackMessage ? null : this.$options.labels.placeHolder; + }, }, watch: { isEmptyInvites: { handler(updatedValue) { // nothing to do if the invites are **still** empty and the emptyInvites were never set from submit - if (!updatedValue && !this.emptyInvitesError) { + if (!updatedValue && !this.shouldShowEmptyInvitesAlert) { return; } @@ -218,6 +234,10 @@ export default { }, }, mounted() { + if (this.reloadPageOnSubmit) { + displaySuccessfulInvitationAlert(); + } + eventHub.$on('openModal', (options) => { this.openModal(options); if (this.isOnLearnGitlab) { @@ -258,16 +278,17 @@ export default { const tracking = new ExperimentTracking(experimentName); tracking.event(eventName); }, - showEmptyInvitesError() { - this.invalidFeedbackMessage = this.$options.labels.emptyInvitesErrorText; - this.emptyInvitesError = true; + showEmptyInvitesAlert() { + this.invalidFeedbackMessage = this.$options.labels.placeHolder; + this.shouldShowEmptyInvitesAlert = true; + this.$refs.alerts.focus(); }, sendInvite({ accessLevel, expiresAt }) { this.isLoading = true; this.clearValidation(); if (!this.isEmptyInvites) { - this.showEmptyInvitesError(); + this.showEmptyInvitesAlert(); return; } @@ -298,7 +319,7 @@ export default { if (error) { this.showMemberErrors(message); } else { - this.showSuccessMessage(); + this.onInviteSuccess(); } }) .catch((e) => this.showInvalidFeedbackMessage(e)) @@ -308,6 +329,7 @@ export default { }, showMemberErrors(message) { this.invalidMembers = message; + this.$refs.alerts.focus(); }, tokenName(username) { // initial token creation hits this and nothing is found... so safe navigation @@ -322,6 +344,7 @@ export default { resetFields() { this.clearValidation(); this.isLoading = false; + this.shouldShowEmptyInvitesAlert = false; this.newUsersToInvite = []; this.selectedTasksToBeDone = []; [this.selectedTaskProject] = this.projects; @@ -329,6 +352,13 @@ export default { changeSelectedTaskProject(project) { this.selectedTaskProject = project; }, + onInviteSuccess() { + if (this.reloadPageOnSubmit) { + reloadOnInvitationSuccess(); + } else { + this.showSuccessMessage(); + } + }, showSuccessMessage() { if (this.isOnLearnGitlab) { eventHub.$emit('showSuccessfulInvitationsAlert'); @@ -347,7 +377,7 @@ export default { }, clearEmptyInviteError() { this.invalidFeedbackMessage = ''; - this.emptyInvitesError = false; + this.shouldShowEmptyInvitesAlert = false; }, removeToken(token) { delete this.invalidMembers[memberName(token)]; @@ -370,12 +400,13 @@ export default { :help-link="helpLink" :label-intro-text="labelIntroText" :label-search-field="$options.labels.searchField" - :form-group-description="$options.labels.placeHolder" + :form-group-description="formGroupDescription" :invalid-feedback-message="invalidFeedbackMessage" :is-loading="isLoading" :new-users-to-invite="newUsersToInvite" :root-group-id="rootId" :users-limit-dataset="usersLimitDataset" + :full-path="fullPath" @reset="resetFields" @submit="sendInvite" @access-level="onAccessLevelUpdate" @@ -390,59 +421,77 @@ export default { </template> <template #alert> - <gl-alert - v-if="hasInvalidMembers" - variant="danger" - :dismissible="false" - :title="memberErrorTitle" - data-testid="alert-member-error" - > - {{ $options.labels.memberErrorListText }} - <ul class="gl-pl-5 gl-mb-0"> - <li v-for="error in errorsLimited" :key="error.member" data-testid="errors-limited-item"> - <strong>{{ error.displayedMemberName }}:</strong> {{ error.message }} - </li> - </ul> - <template v-if="shouldErrorsSectionExpand"> - <gl-collapse v-model="isErrorsSectionExpanded"> - <ul class="gl-pl-5 gl-mb-0"> - <li - v-for="error in errorsExpanded" - :key="error.member" - data-testid="errors-expanded-item" - > - <strong>{{ error.displayedMemberName }}:</strong> {{ error.message }} - </li> - </ul> - </gl-collapse> - <gl-button - class="gl-text-decoration-none! gl-shadow-none! gl-mt-3" - data-testid="accordion-button" - variant="link" - @click="toggleErrorExpansion" - > - {{ errorCollapseText }} - <gl-icon - name="chevron-down" - class="gl-transition-medium" - :class="{ 'gl-rotate-180': isErrorsSectionExpanded }" - /> - </gl-button> - </template> - </gl-alert> - <user-limit-notification - v-else-if="showUserLimitNotification" - :limit-variant="limitVariant" - :users-limit-dataset="usersLimitDataset" - /> + <div ref="alerts" tabindex="-1"> + <gl-alert + v-if="shouldShowEmptyInvitesAlert" + id="empty-invites-alert" + class="gl-mb-4" + variant="danger" + :dismissible="false" + data-testid="empty-invites-alert" + > + {{ $options.labels.emptyInvitesAlertText }} + </gl-alert> + <gl-alert + v-if="hasInvalidMembers" + class="gl-mb-4" + variant="danger" + :dismissible="false" + :title="memberErrorTitle" + data-testid="alert-member-error" + > + {{ $options.labels.memberErrorListText }} + <ul class="gl-pl-5 gl-mb-0"> + <li + v-for="error in errorsLimited" + :key="error.member" + data-testid="errors-limited-item" + > + <strong>{{ error.displayedMemberName }}:</strong> {{ error.message }} + </li> + </ul> + <template v-if="shouldErrorsSectionExpand"> + <gl-collapse v-model="isErrorsSectionExpanded"> + <ul class="gl-pl-5 gl-mb-0"> + <li + v-for="error in errorsExpanded" + :key="error.member" + data-testid="errors-expanded-item" + > + <strong>{{ error.displayedMemberName }}:</strong> {{ error.message }} + </li> + </ul> + </gl-collapse> + <gl-button + class="gl-text-decoration-none! gl-shadow-none! gl-mt-3" + data-testid="accordion-button" + variant="link" + @click="toggleErrorExpansion" + > + {{ errorCollapseText }} + <gl-icon + name="chevron-down" + class="gl-transition-medium" + :class="{ 'gl-rotate-180': isErrorsSectionExpanded }" + /> + </gl-button> + </template> + </gl-alert> + <user-limit-notification + v-else-if="showUserLimitNotification" + :limit-variant="limitVariant" + :users-limit-dataset="usersLimitDataset" + /> + </div> </template> - <template #select="{ exceptionState, labelId }"> + <template #select="{ exceptionState, inputId }"> <members-token-select v-model="newUsersToInvite" class="gl-mb-2" + aria-labelledby="empty-invites-alert" + :input-id="inputId" :exception-state="exceptionState" - :aria-labelledby="labelId" :users-filter="usersFilter" :filter-id="filterId" :invalid-members="invalidMembers" diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue index e3511a49fc5..2cbd681c67d 100644 --- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue +++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue @@ -1,14 +1,5 @@ <script> -import { - GlFormGroup, - GlModal, - GlDropdown, - GlDropdownItem, - GlDatepicker, - GlLink, - GlSprintf, - GlFormInput, -} from '@gitlab/ui'; +import { GlFormGroup, GlFormSelect, GlModal, GlDatepicker, GlLink, GlSprintf } from '@gitlab/ui'; import Tracking from '~/tracking'; import { sprintf } from '~/locale'; import ContentTransition from '~/vue_shared/components/content_transition.vue'; @@ -37,13 +28,11 @@ const DEFAULT_SLOTS = [ export default { components: { GlFormGroup, + GlFormSelect, GlDatepicker, GlLink, GlModal, - GlDropdown, - GlDropdownItem, GlSprintf, - GlFormInput, ContentTransition, }, mixins: [Tracking.mixin()], @@ -141,14 +130,23 @@ export default { }; }, computed: { + accessLevelsOptions() { + return Object.entries(this.accessLevels).map(([text, value]) => ({ text, value })); + }, introText() { return sprintf(this.labelIntroText, { name: this.name }); }, exceptionState() { return this.invalidFeedbackMessage ? false : null; }, - selectLabelId() { - return `${this.modalId}_select`; + selectId() { + return `${this.modalId}_search`; + }, + dropdownId() { + return `${this.modalId}_dropdown`; + }, + datepickerId() { + return `${this.modalId}_expires_at`; }, selectedRoleName() { return Object.keys(this.accessLevels).find( @@ -218,9 +216,6 @@ export default { this.$emit('cancel'); }, - changeSelectedItem(item) { - this.selectedAccessLevel = item; - }, onSubmit(e) { // We never want to hide when submitting e.preventDefault(); @@ -279,64 +274,50 @@ export default { <slot name="alert"></slot> <gl-form-group + :label="labelSearchField" + :label-for="selectId" :invalid-feedback="invalidFeedbackMessage" :state="exceptionState" :description="formGroupDescription" data-testid="members-form-group" > - <label :id="selectLabelId" class="col-form-label">{{ labelSearchField }}</label> - <slot name="select" v-bind="{ exceptionState, labelId: selectLabelId }"></slot> + <slot name="select" v-bind="{ exceptionState, inputId: selectId }"></slot> </gl-form-group> - <label class="gl-font-weight-bold">{{ $options.ACCESS_LEVEL }}</label> - <div class="gl-mt-2 gl-w-half gl-xs-w-full"> - <gl-dropdown - class="gl-shadow-none gl-w-full" + <gl-form-group + class="gl-w-half gl-xs-w-full" + :label="$options.ACCESS_LEVEL" + :label-for="dropdownId" + > + <template #description> + <gl-sprintf :message="$options.READ_MORE_TEXT"> + <template #link="{ content }"> + <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </template> + <gl-form-select + :id="dropdownId" + v-model="selectedAccessLevel" data-qa-selector="access_level_dropdown" - v-bind="$attrs" - :text="selectedRoleName" - > - <template v-for="(key, item) in accessLevels"> - <gl-dropdown-item - :key="key" - active-class="is-active" - is-check-item - :is-checked="key === selectedAccessLevel" - @click="changeSelectedItem(key)" - > - <div>{{ item }}</div> - </gl-dropdown-item> - </template> - </gl-dropdown> - </div> - - <div class="gl-mt-2 gl-w-half gl-xs-w-full"> - <gl-sprintf :message="$options.READ_MORE_TEXT"> - <template #link="{ content }"> - <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </div> + :options="accessLevelsOptions" + /> + </gl-form-group> - <label class="gl-mt-5 gl-display-block" for="expires_at">{{ - $options.ACCESS_EXPIRE_DATE - }}</label> - <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block"> + <gl-form-group + class="gl-w-half gl-xs-w-full" + :label="$options.ACCESS_EXPIRE_DATE" + :label-for="datepickerId" + > <gl-datepicker v-model="selectedDate" - class="gl-display-inline!" + :input-id="datepickerId" + class="gl-display-block!" :min-date="minDate" :target="null" - > - <template #default="{ formattedDate }"> - <gl-form-input - class="gl-w-full" - :value="formattedDate" - :placeholder="__(`YYYY-MM-DD`)" - /> - </template> - </gl-datepicker> - </div> + /> + </gl-form-group> + <slot name="form-after"></slot> </template> diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue index 2ddb04e1eeb..68602068699 100644 --- a/app/assets/javascripts/invite_members/components/members_token_select.vue +++ b/app/assets/javascripts/invite_members/components/members_token_select.vue @@ -49,6 +49,11 @@ export default { type: Object, required: true, }, + inputId: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -84,6 +89,13 @@ export default { hasInvalidMembers() { return !isEmpty(this.invalidMembers); }, + textInputAttrs() { + return { + 'data-testid': 'members-token-select-input', + 'data-qa-selector': 'members_token_select_input', + id: this.inputId, + }; + }, }, watch: { // We might not really want this to be *reactive* since we want the "class" state to be @@ -183,10 +195,7 @@ export default { :hide-dropdown-with-no-items="hideDropdownWithNoItems" :placeholder="placeholderText" :aria-labelledby="ariaLabelledby" - :text-input-attrs="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { - 'data-testid': 'members-token-select-input', - 'data-qa-selector': 'members_token_select_input', - } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + :text-input-attrs="textInputAttrs" @blur="handleBlur" @text-input="handleTextInput" @input="handleInput" diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index de7b1019782..a894eb24d38 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -9,6 +9,7 @@ export const INVITE_MEMBERS_FOR_TASK = { view: 'modal_opened_from_email', submit: 'submit', }; +export const TOAST_MESSAGE_LOCALSTORAGE_KEY = 'members_invited_successfully'; export const GROUP_FILTERS = { ALL: 'all', @@ -57,6 +58,10 @@ export const GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT = s__( "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} project.", ); +export const GROUP_MODAL_ALERT_BODY = s__( + 'InviteMembersModal| Inviting a group %{linkStart}adds its members to your group%{linkEnd}, including members who join after the invite. This might put your group over the free %{count} user limit.', +); + export const GROUP_SEARCH_FIELD = s__('InviteMembersModal|Select a group to invite'); export const GROUP_PLACEHOLDER = s__('InviteMembersModal|Search for a group to invite'); @@ -77,9 +82,7 @@ export const MEMBER_ERROR_LIST_TEXT = s__( ); export const COLLAPSED_ERRORS = s__('InviteMembersModal|Show more (%{count})'); export const EXPANDED_ERRORS = s__('InviteMembersModal|Show less'); -export const EMPTY_INVITES_ERROR_TEXT = s__( - 'InviteMembersModal|Please select members or type email addresses to invite', -); +export const EMPTY_INVITES_ALERT_TEXT = s__('InviteMembersModal|Please add members to invite'); export const MEMBER_MODAL_LABELS = { modal: { @@ -117,7 +120,7 @@ export const MEMBER_MODAL_LABELS = { memberErrorListText: MEMBER_ERROR_LIST_TEXT, collapsedErrors: COLLAPSED_ERRORS, expandedErrors: EXPANDED_ERRORS, - emptyInvitesErrorText: EMPTY_INVITES_ERROR_TEXT, + emptyInvitesAlertText: EMPTY_INVITES_ALERT_TEXT, }; export const GROUP_MODAL_LABELS = { diff --git a/app/assets/javascripts/invite_members/init_import_project_members_modal.js b/app/assets/javascripts/invite_members/init_import_project_members_modal.js index daaa1315884..227d8395250 100644 --- a/app/assets/javascripts/invite_members/init_import_project_members_modal.js +++ b/app/assets/javascripts/invite_members/init_import_project_members_modal.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import ImportProjectMembersModal from '~/invite_members/components/import_project_members_modal.vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; export default function initImportProjectMembersModal() { const el = document.querySelector('.js-import-project-members-modal'); @@ -8,7 +9,7 @@ export default function initImportProjectMembersModal() { return false; } - const { projectId, projectName } = el.dataset; + const { projectId, projectName, reloadPageOnSubmit } = el.dataset; return new Vue({ el, @@ -17,6 +18,7 @@ export default function initImportProjectMembersModal() { props: { projectId, projectName, + reloadPageOnSubmit: parseBoolean(reloadPageOnSubmit), }, }), }); diff --git a/app/assets/javascripts/invite_members/init_invite_groups_modal.js b/app/assets/javascripts/invite_members/init_invite_groups_modal.js index be1576ad0b0..53b756b610f 100644 --- a/app/assets/javascripts/invite_members/init_invite_groups_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_groups_modal.js @@ -28,6 +28,9 @@ export default function initInviteGroupsModal() { return new Vue({ el, + provide: { + freeUsersLimit: parseInt(el.dataset.freeUsersLimit, 10), + }, render: (createElement) => createElement(InviteGroupsModal, { props: { @@ -38,6 +41,8 @@ export default function initInviteGroupsModal() { groupSelectFilter: el.dataset.groupsFilter, groupSelectParentId: parseInt(el.dataset.parentId, 10), invalidGroups: JSON.parse(el.dataset.invalidGroups || '[]'), + freeUserCapEnabled: parseBoolean(el.dataset.freeUserCapEnabled), + reloadPageOnSubmit: parseBoolean(el.dataset.reloadPageOnSubmit), }, }), }); diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js index a4be3f205a3..842ab07f368 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -41,6 +41,7 @@ export default (function initInviteMembersModal() { usersLimitDataset: convertObjectPropsToCamelCase( JSON.parse(el.dataset.usersLimitDataset || '{}'), ), + reloadPageOnSubmit: parseBoolean(el.dataset.reloadPageOnSubmit), }, }), }); diff --git a/app/assets/javascripts/invite_members/utils/trigger_successful_invite_alert.js b/app/assets/javascripts/invite_members/utils/trigger_successful_invite_alert.js new file mode 100644 index 00000000000..4d3a7951265 --- /dev/null +++ b/app/assets/javascripts/invite_members/utils/trigger_successful_invite_alert.js @@ -0,0 +1,23 @@ +import { createAlert } from '~/flash'; +import AccessorUtilities from '~/lib/utils/accessor'; + +import { TOAST_MESSAGE_LOCALSTORAGE_KEY, TOAST_MESSAGE_SUCCESSFUL } from '../constants'; + +export function displaySuccessfulInvitationAlert() { + if (!AccessorUtilities.canUseLocalStorage()) { + return; + } + + const showAlert = Boolean(localStorage.getItem(TOAST_MESSAGE_LOCALSTORAGE_KEY)); + if (showAlert) { + localStorage.removeItem(TOAST_MESSAGE_LOCALSTORAGE_KEY); + createAlert({ message: TOAST_MESSAGE_SUCCESSFUL, variant: 'info' }); + } +} + +export function reloadOnInvitationSuccess() { + if (AccessorUtilities.canUseLocalStorage()) { + localStorage.setItem(TOAST_MESSAGE_LOCALSTORAGE_KEY, 'true'); + } + window.location.reload(); +} diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/constants.js b/app/assets/javascripts/issuable/bulk_update_sidebar/constants.js deleted file mode 100644 index 68133ceb3c7..00000000000 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/constants.js +++ /dev/null @@ -1,23 +0,0 @@ -import { __ } from '~/locale'; - -export const statusDropdownOptions = [ - { - text: __('Open'), - value: 'reopen', - }, - { - text: __('Closed'), - value: 'close', - }, -]; - -export const subscriptionsDropdownOptions = [ - { - text: __('Subscribe'), - value: 'subscribe', - }, - { - text: __('Unsubscribe'), - value: 'unsubscribe', - }, -]; diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/index.js b/app/assets/javascripts/issuable/bulk_update_sidebar/index.js deleted file mode 100644 index b7cb805ee37..00000000000 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/index.js +++ /dev/null @@ -1,75 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import { gqlClient } from '../../issues/list/graphql'; -import StatusDropdown from './components/status_dropdown.vue'; -import SubscriptionsDropdown from './components/subscriptions_dropdown.vue'; -import MoveIssuesButton from './components/move_issues_button.vue'; -import issuableBulkUpdateActions from './issuable_bulk_update_actions'; -import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar'; - -export function initBulkUpdateSidebar(prefixId) { - const el = document.querySelector('.issues-bulk-update'); - - if (!el) { - return; - } - - issuableBulkUpdateActions.init({ prefixId }); - new IssuableBulkUpdateSidebar(); // eslint-disable-line no-new -} - -export function initStatusDropdown() { - const el = document.querySelector('.js-status-dropdown'); - - if (!el) { - return null; - } - - return new Vue({ - el, - name: 'StatusDropdownRoot', - render: (createElement) => createElement(StatusDropdown), - }); -} - -export function initSubscriptionsDropdown() { - const el = document.querySelector('.js-subscriptions-dropdown'); - - if (!el) { - return null; - } - - return new Vue({ - el, - name: 'SubscriptionsDropdownRoot', - render: (createElement) => createElement(SubscriptionsDropdown), - }); -} - -export function initMoveIssuesButton() { - const el = document.querySelector('.js-move-issues'); - - if (!el) { - return null; - } - - const { dataset } = el; - - Vue.use(VueApollo); - const apolloProvider = new VueApollo({ - defaultClient: gqlClient, - }); - - return new Vue({ - el, - name: 'MoveIssuesRoot', - apolloProvider, - render: (createElement) => - createElement(MoveIssuesButton, { - props: { - projectFullPath: dataset.projectFullPath, - projectsFetchPath: dataset.projectsFetchPath, - }, - }), - }); -} diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue index 254248ef1d4..fd55f05e955 100644 --- a/app/assets/javascripts/issuable/components/related_issuable_item.vue +++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue @@ -1,13 +1,7 @@ <script> import '~/commons/bootstrap'; -import { - GlIcon, - GlLink, - GlTooltip, - GlTooltipDirective, - GlButton, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; +import { GlIcon, GlLink, GlTooltip, GlTooltipDirective, GlButton } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import IssueDueDate from '~/boards/components/issue_due_date.vue'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; diff --git a/app/assets/javascripts/issuable/index.js b/app/assets/javascripts/issuable/index.js index 10dbefce503..ed336deb2ed 100644 --- a/app/assets/javascripts/issuable/index.js +++ b/app/assets/javascripts/issuable/index.js @@ -1,12 +1,25 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; -import IssuableContext from '~/issuable/issuable_context'; import { parseBoolean } from '~/lib/utils/common_utils'; import Sidebar from '~/right_sidebar'; import { getSidebarOptions } from '~/sidebar/mount_sidebar'; import CsvImportExportButtons from './components/csv_import_export_buttons.vue'; import IssuableByEmail from './components/issuable_by_email.vue'; import IssuableHeaderWarnings from './components/issuable_header_warnings.vue'; +import issuableBulkUpdateActions from './issuable_bulk_update_actions'; +import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar'; +import IssuableContext from './issuable_context'; + +export function initBulkUpdateSidebar(prefixId) { + const el = document.querySelector('.issues-bulk-update'); + + if (!el) { + return; + } + + issuableBulkUpdateActions.init({ prefixId }); + new IssuableBulkUpdateSidebar(); // eslint-disable-line no-new +} export function initCsvImportExportButtons() { const el = document.querySelector('.js-csv-import-export-buttons'); diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable/issuable_bulk_update_actions.js index 14824820c0d..c386267501a 100644 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable/issuable_bulk_update_actions.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import { difference, intersection, union } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -32,7 +32,7 @@ export default { onFormSubmitFailure() { this.form.find('[type="submit"]').enable(); - return createFlash({ + return createAlert({ message: __('Issue update failed'), }); }, diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js index b46a95c7dfa..095da60a583 100644 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js @@ -3,7 +3,12 @@ import $ from 'jquery'; import issuableEventHub from '~/issues/list/eventhub'; import LabelsSelect from '~/labels/labels_select'; -import { mountMilestoneDropdown } from '~/sidebar/mount_sidebar'; +import { + mountMilestoneDropdown, + mountMoveIssuesButton, + mountStatusDropdown, + mountSubscriptionsDropdown, +} from '~/sidebar/mount_sidebar'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; const HIDDEN_CLASS = 'hidden'; @@ -56,6 +61,9 @@ export default class IssuableBulkUpdateSidebar { initDropdowns() { new LabelsSelect(); mountMilestoneDropdown(); + mountMoveIssuesButton(); + mountStatusDropdown(); + mountSubscriptionsDropdown(); // Checking IS_EE and using ee_else_ce is odd, but we do it here to satisfy // the import/no-unresolved lint rule when FOSS_ONLY=1, even though at diff --git a/app/assets/javascripts/issuable/issuable_label_selector.js b/app/assets/javascripts/issuable/issuable_label_selector.js new file mode 100644 index 00000000000..ad8bbf04d6f --- /dev/null +++ b/app/assets/javascripts/issuable/issuable_label_selector.js @@ -0,0 +1,56 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { + DropdownVariant, + LabelType, +} from '~/sidebar/components/labels/labels_select_widget/constants'; +import { WorkspaceType } from '~/issues/constants'; +import IssuableLabelSelector from '~/vue_shared/issuable/create/components/issuable_label_selector.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export default () => { + const el = document.querySelector('.js-issuable-form-label-selector'); + + if (!el) { + return false; + } + + const { + fieldName, + fullPath, + initialLabels, + issuableType, + labelsFilterBasePath, + labelsManagePath, + } = el.dataset; + + return new Vue({ + el, + apolloProvider, + provide: { + allowLabelCreate: true, + allowLabelEdit: true, + allowLabelRemove: true, + allowScopedLabels: true, + attrWorkspacePath: fullPath, + fieldName, + fullPath, + initialLabels: JSON.parse(initialLabels), + issuableType, + labelType: LabelType.project, + labelsFilterBasePath, + labelsManagePath, + variant: DropdownVariant.Embedded, + workspaceType: WorkspaceType.project, + }, + render(createElement) { + return createElement(IssuableLabelSelector); + }, + }); +}; diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js index 92ff7f21eff..977a505437d 100644 --- a/app/assets/javascripts/issues/create_merge_request_dropdown.js +++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js @@ -7,7 +7,7 @@ import { import confidentialMergeRequestState from '~/confidential_merge_request/state'; import DropLab from '~/filtered_search/droplab/drop_lab_deprecated'; import ISetter from '~/filtered_search/droplab/plugins/input_setter'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; import { mergeUrlParams } from '~/lib/utils/url_utility'; @@ -141,7 +141,7 @@ export default class CreateMergeRequestDropdown { .catch(() => { this.unavailable(); this.disable(); - createFlash({ + createAlert({ message: __('Failed to check related branches.'), }); }); @@ -162,7 +162,7 @@ export default class CreateMergeRequestDropdown { } }) .catch(() => - createFlash({ + createAlert({ message: __('Failed to create a branch for this issue. Please try again.'), }), ); @@ -293,7 +293,7 @@ export default class CreateMergeRequestDropdown { } this.unavailable(); this.disable(); - createFlash({ + createAlert({ message: __('Failed to get ref.'), }); diff --git a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue index 29f6aecca03..b9d876ef72f 100644 --- a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue +++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue @@ -1,13 +1,50 @@ <script> -import { GlButton, GlEmptyState } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlTooltipDirective } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import getIssuesQuery from 'ee_else_ce/issues/dashboard/queries/get_issues.query.graphql'; +import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue'; +import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue'; +import { IssuableStatus } from '~/issues/constants'; +import { + CREATED_DESC, + PAGE_SIZE, + PARAM_STATE, + UPDATED_DESC, + urlSortParams, +} from '~/issues/list/constants'; +import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql'; +import { + convertToApiParams, + convertToSearchQuery, + convertToUrlParams, + getFilterTokens, + getInitialPageParams, + getSortKey, + getSortOptions, + isSortKey, +} from '~/issues/list/utils'; +import axios from '~/lib/utils/axios_utils'; +import { scrollUp } from '~/lib/utils/scroll_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; +import { + TOKEN_TITLE_ASSIGNEE, + TOKEN_TITLE_AUTHOR, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, +} from '~/vue_shared/components/filtered_search_bar/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants'; +const UserToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'); + export default { i18n: { calendarButtonText: __('Subscribe to calendar'), + closed: __('CLOSED'), + closedMoved: __('CLOSED (MOVED)'), emptyStateTitle: __('Please select at least one filter to see results'), + errorFetchingIssues: __('An error occurred while loading issues'), rssButtonText: __('Subscribe to RSS feed'), searchInputPlaceholder: __('Search or filter results...'), }, @@ -16,29 +53,237 @@ export default { GlButton, GlEmptyState, IssuableList, + IssueCardStatistics, + IssueCardTimeInfo, + }, + directives: { + GlTooltip: GlTooltipDirective, }, - inject: ['calendarPath', 'emptyStateSvgPath', 'isSignedIn', 'rssPath'], + inject: [ + 'calendarPath', + 'emptyStateSvgPath', + 'hasBlockedIssuesFeature', + 'hasIssuableHealthStatusFeature', + 'hasIssueWeightsFeature', + 'hasScopedLabelsFeature', + 'initialSort', + 'isPublicVisibilityRestricted', + 'isSignedIn', + 'rssPath', + ], data() { + const state = getParameterByName(PARAM_STATE); + + const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC; + const dashboardSortKey = getSortKey(this.initialSort); + const graphQLSortKey = + isSortKey(this.initialSort?.toUpperCase()) && this.initialSort.toUpperCase(); + + // The initial sort is an old enum value when it is saved on the dashboard issues page. + // The initial sort is a GraphQL enum value when it is saved on the Vue issues list page. + const sortKey = dashboardSortKey || graphQLSortKey || defaultSortKey; + return { + filterTokens: getFilterTokens(window.location.search), issues: [], - searchTokens: [], - sortOptions: [], - state: IssuableStates.Opened, + issuesError: null, + pageInfo: {}, + pageParams: getInitialPageParams(), + sortKey, + state: state || IssuableStates.Opened, }; }, + apollo: { + issues: { + query: getIssuesQuery, + variables() { + return { + hideUsers: this.isPublicVisibilityRestricted && !this.isSignedIn, + isSignedIn: this.isSignedIn, + search: this.searchQuery, + sort: this.sortKey, + state: this.state, + ...this.pageParams, + ...this.apiFilterParams, + }; + }, + update(data) { + return data.issues.nodes ?? []; + }, + result({ data }) { + this.pageInfo = data?.issues.pageInfo ?? {}; + }, + error(error) { + this.issuesError = this.$options.i18n.errorFetchingIssues; + Sentry.captureException(error); + }, + debounce: 200, + }, + }, + computed: { + apiFilterParams() { + return convertToApiParams(this.filterTokens); + }, + searchQuery() { + return convertToSearchQuery(this.filterTokens); + }, + searchTokens() { + const preloadedUsers = []; + + if (gon.current_user_id) { + preloadedUsers.push({ + id: gon.current_user_id, + name: gon.current_user_fullname, + username: gon.current_username, + avatar_url: gon.current_user_avatar_url, + }); + } + + const tokens = [ + { + type: TOKEN_TYPE_ASSIGNEE, + title: TOKEN_TITLE_ASSIGNEE, + icon: 'user', + token: UserToken, + fetchUsers: this.fetchUsers, + preloadedUsers, + recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-assignee', + }, + { + type: TOKEN_TYPE_AUTHOR, + title: TOKEN_TITLE_AUTHOR, + icon: 'pencil', + token: UserToken, + fetchUsers: this.fetchUsers, + defaultUsers: [], + preloadedUsers, + recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-author', + }, + ]; + + return tokens; + }, + showPaginationControls() { + return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage); + }, + sortOptions() { + return getSortOptions({ + hasBlockedIssuesFeature: this.hasBlockedIssuesFeature, + hasIssuableHealthStatusFeature: this.hasIssuableHealthStatusFeature, + hasIssueWeightsFeature: this.hasIssueWeightsFeature, + }); + }, + urlFilterParams() { + return convertToUrlParams(this.filterTokens); + }, + urlParams() { + return { + search: this.searchQuery, + sort: urlSortParams[this.sortKey], + state: this.state, + ...this.urlFilterParams, + }; + }, + }, + methods: { + fetchUsers(search) { + return axios.get('/-/autocomplete/users.json', { params: { active: true, search } }); + }, + getStatus(issue) { + if (issue.state === IssuableStatus.Closed && issue.moved) { + return this.$options.i18n.closedMoved; + } + if (issue.state === IssuableStatus.Closed) { + return this.$options.i18n.closed; + } + return undefined; + }, + handleClickTab(state) { + if (this.state === state) { + return; + } + this.state = state; + this.pageParams = getInitialPageParams(); + }, + handleDismissAlert() { + this.issuesError = null; + }, + handleFilter(tokens) { + this.filterTokens = tokens; + this.pageParams = getInitialPageParams(); + }, + handleNextPage() { + this.pageParams = { + afterCursor: this.pageInfo.endCursor, + firstPageSize: PAGE_SIZE, + }; + scrollUp(); + }, + handlePreviousPage() { + this.pageParams = { + beforeCursor: this.pageInfo.startCursor, + lastPageSize: PAGE_SIZE, + }; + scrollUp(); + }, + handleSort(sortKey) { + if (this.sortKey === sortKey) { + return; + } + + this.sortKey = sortKey; + this.pageParams = getInitialPageParams(); + + if (this.isSignedIn) { + this.saveSortPreference(sortKey); + } + }, + saveSortPreference(sortKey) { + this.$apollo + .mutate({ + mutation: setSortPreferenceMutation, + variables: { input: { issuesSort: sortKey } }, + }) + .then(({ data }) => { + if (data.userPreferencesUpdate.errors.length) { + throw new Error(data.userPreferencesUpdate.errors); + } + }) + .catch((error) => { + Sentry.captureException(error); + }); + }, + }, }; </script> <template> <issuable-list + :current-tab="state" + :error="issuesError" + :has-next-page="pageInfo.hasNextPage" + :has-previous-page="pageInfo.hasPreviousPage" + :has-scoped-labels-feature="hasScopedLabelsFeature" + :initial-filter-value="filterTokens" + :initial-sort-by="sortKey" + :issuables="issues" + :issuables-loading="$apollo.queries.issues.loading" namespace="dashboard" recent-searches-storage-key="issues" :search-input-placeholder="$options.i18n.searchInputPlaceholder" :search-tokens="searchTokens" + :show-pagination-controls="showPaginationControls" + show-work-item-type-icon :sort-options="sortOptions" - :issuables="issues" :tabs="$options.IssuableListTabs" - :current-tab="state" + :url-params="urlParams" + use-keyset-pagination + @click-tab="handleClickTab" + @dismiss-alert="handleDismissAlert" + @filter="handleFilter" + @next-page="handleNextPage" + @previous-page="handlePreviousPage" + @sort="handleSort" > <template #nav-actions> <gl-button :href="rssPath" icon="rss"> @@ -49,6 +294,18 @@ export default { </gl-button> </template> + <template #timeframe="{ issuable = {} }"> + <issue-card-time-info :issue="issuable" /> + </template> + + <template #status="{ issuable = {} }"> + {{ getStatus(issuable) }} + </template> + + <template #statistics="{ issuable = {} }"> + <issue-card-statistics :issue="issuable" /> + </template> + <template #empty-state> <gl-empty-state :svg-path="emptyStateSvgPath" :title="$options.i18n.emptyStateTitle" /> </template> diff --git a/app/assets/javascripts/issues/dashboard/index.js b/app/assets/javascripts/issues/dashboard/index.js index a1ae3b93f7d..e3e5cc614cb 100644 --- a/app/assets/javascripts/issues/dashboard/index.js +++ b/app/assets/javascripts/issues/dashboard/index.js @@ -1,4 +1,6 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; import IssuesDashboardApp from './components/issues_dashboard_app.vue'; @@ -9,14 +11,36 @@ export function mountIssuesDashboardApp() { return null; } - const { calendarPath, emptyStateSvgPath, isSignedIn, rssPath } = el.dataset; + Vue.use(VueApollo); + + const { + calendarPath, + emptyStateSvgPath, + hasBlockedIssuesFeature, + hasIssuableHealthStatusFeature, + hasIssueWeightsFeature, + hasScopedLabelsFeature, + initialSort, + isPublicVisibilityRestricted, + isSignedIn, + rssPath, + } = el.dataset; return new Vue({ el, name: 'IssuesDashboardRoot', + apolloProvider: new VueApollo({ + defaultClient: createDefaultClient(), + }), provide: { calendarPath, emptyStateSvgPath, + hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature), + hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), + hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), + hasScopedLabelsFeature: parseBoolean(hasScopedLabelsFeature), + initialSort, + isPublicVisibilityRestricted: parseBoolean(isPublicVisibilityRestricted), isSignedIn: parseBoolean(isSignedIn), rssPath, }, diff --git a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql new file mode 100644 index 00000000000..8ffcb456755 --- /dev/null +++ b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql @@ -0,0 +1,36 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "~/issues/list/queries/issue.fragment.graphql" + +query getDashboardIssues( + $hideUsers: Boolean = false + $isSignedIn: Boolean = false + $search: String + $sort: IssueSort + $state: IssuableState + $assigneeUsernames: [String!] + $authorUsername: String + $afterCursor: String + $beforeCursor: String + $firstPageSize: Int + $lastPageSize: Int +) { + issues( + search: $search + sort: $sort + state: $state + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + after: $afterCursor + before: $beforeCursor + first: $firstPageSize + last: $lastPageSize + ) { + nodes { + ...IssueFragment + reference(full: true) + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js index a785790169d..e3716d0e111 100644 --- a/app/assets/javascripts/issues/index.js +++ b/app/assets/javascripts/issues/index.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import IssuableForm from 'ee_else_ce/issuable/issuable_form'; +import IssuableLabelSelector from '~/issuable/issuable_label_selector'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import GLForm from '~/gl_form'; @@ -39,6 +40,7 @@ export function initFilteredSearchServiceDesk() { export function initForm() { new GLForm($('.issue-form')); // eslint-disable-line no-new new IssuableForm($('.issue-form')); // eslint-disable-line no-new + IssuableLabelSelector(); new IssuableTemplateSelectors({ warnTemplateOverride: true }); // eslint-disable-line no-new new LabelsSelect(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/issues/issue.js b/app/assets/javascripts/issues/issue.js index a9321cf200d..de1c689e590 100644 --- a/app/assets/javascripts/issues/issue.js +++ b/app/assets/javascripts/issues/issue.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import { joinPaths } from '~/lib/utils/url_utility'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import axios from '~/lib/utils/axios_utils'; import { addDelimiter } from '~/lib/utils/text_utility'; @@ -68,7 +68,7 @@ export default class Issue { this.createMergeRequestDropdown.checkAbilityToCreateBranch(); } } else { - createFlash({ + createAlert({ message: issueFailMessage, }); } @@ -105,7 +105,7 @@ export default class Issue { } }) .catch(() => - createFlash({ + createAlert({ message: __('Failed to load related branches'), }), ); diff --git a/app/assets/javascripts/issues/list/components/empty_state_with_any_issues.vue b/app/assets/javascripts/issues/list/components/empty_state_with_any_issues.vue new file mode 100644 index 00000000000..8aece24de0c --- /dev/null +++ b/app/assets/javascripts/issues/list/components/empty_state_with_any_issues.vue @@ -0,0 +1,53 @@ +<script> +import { GlButton, GlEmptyState } from '@gitlab/ui'; +import { i18n } from '../constants'; + +export default { + i18n, + components: { + GlButton, + GlEmptyState, + }, + inject: ['emptyStateSvgPath', 'newIssuePath', 'showNewIssueLink'], + props: { + hasSearch: { + type: Boolean, + required: true, + }, + isOpenTab: { + type: Boolean, + required: true, + }, + }, +}; +</script> + +<template> + <gl-empty-state + v-if="hasSearch" + :description="$options.i18n.noSearchResultsDescription" + :title="$options.i18n.noSearchResultsTitle" + :svg-path="emptyStateSvgPath" + > + <template #actions> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + </template> + </gl-empty-state> + + <gl-empty-state + v-else-if="isOpenTab" + :description="$options.i18n.noOpenIssuesDescription" + :title="$options.i18n.noOpenIssuesTitle" + :svg-path="emptyStateSvgPath" + > + <template #actions> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + </template> + </gl-empty-state> + + <gl-empty-state v-else :title="$options.i18n.noClosedIssuesTitle" :svg-path="emptyStateSvgPath" /> +</template> diff --git a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue new file mode 100644 index 00000000000..5a37751410a --- /dev/null +++ b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue @@ -0,0 +1,110 @@ +<script> +import { GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; +import { i18n } from '../constants'; +import NewIssueDropdown from './new_issue_dropdown.vue'; + +export default { + i18n, + issuesHelpPagePath: helpPagePath('user/project/issues/index'), + components: { + CsvImportExportButtons, + GlButton, + GlEmptyState, + GlLink, + GlSprintf, + NewIssueDropdown, + }, + inject: [ + 'canCreateProjects', + 'emptyStateSvgPath', + 'isSignedIn', + 'jiraIntegrationPath', + 'newIssuePath', + 'newProjectPath', + 'showNewIssueLink', + 'signInPath', + ], + props: { + currentTabCount: { + type: Number, + required: false, + default: undefined, + }, + exportCsvPathWithQuery: { + type: String, + required: false, + default: '', + }, + showCsvButtons: { + type: Boolean, + required: false, + default: false, + }, + showNewIssueDropdown: { + type: Boolean, + required: false, + default: false, + }, + }, +}; +</script> + +<template> + <div v-if="isSignedIn"> + <gl-empty-state :title="$options.i18n.noIssuesTitle" :svg-path="emptyStateSvgPath"> + <template #description> + <gl-link :href="$options.issuesHelpPagePath"> + {{ $options.i18n.noIssuesDescription }} + </gl-link> + <p v-if="canCreateProjects"> + <strong>{{ $options.i18n.noGroupIssuesSignedInDescription }}</strong> + </p> + </template> + <template #actions> + <gl-button v-if="canCreateProjects" :href="newProjectPath" variant="confirm"> + {{ $options.i18n.newProjectLabel }} + </gl-button> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + <csv-import-export-buttons + v-if="showCsvButtons" + class="gl-w-full gl-sm-w-auto gl-sm-mr-3" + :export-csv-path="exportCsvPathWithQuery" + :issuable-count="currentTabCount" + /> + <new-issue-dropdown v-if="showNewIssueDropdown" class="gl-align-self-center" /> + </template> + </gl-empty-state> + <hr /> + <p class="gl-text-center gl-font-weight-bold gl-mb-0"> + {{ $options.i18n.jiraIntegrationTitle }} + </p> + <p class="gl-text-center gl-mb-0"> + <gl-sprintf :message="$options.i18n.jiraIntegrationMessage"> + <template #jiraDocsLink="{ content }"> + <gl-link :href="jiraIntegrationPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + <p class="gl-text-center gl-text-secondary"> + {{ $options.i18n.jiraIntegrationSecondaryMessage }} + </p> + </div> + + <gl-empty-state + v-else + :title="$options.i18n.noIssuesTitle" + :svg-path="emptyStateSvgPath" + :primary-button-text="$options.i18n.noIssuesSignedOutButtonText" + :primary-button-link="signInPath" + > + <template #description> + <gl-link :href="$options.issuesHelpPagePath"> + {{ $options.i18n.noIssuesDescription }} + </gl-link> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/issues/list/components/issue_card_statistics.vue b/app/assets/javascripts/issues/list/components/issue_card_statistics.vue new file mode 100644 index 00000000000..2d00c3e549d --- /dev/null +++ b/app/assets/javascripts/issues/list/components/issue_card_statistics.vue @@ -0,0 +1,56 @@ +<script> +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { i18n } from '../constants'; + +export default { + i18n, + components: { + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + issue: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <ul class="gl-display-contents"> + <li + v-if="issue.mergeRequestsCount" + v-gl-tooltip + class="gl-display-none gl-sm-display-block gl-mr-3" + :title="$options.i18n.relatedMergeRequests" + data-testid="merge-requests" + > + <gl-icon name="merge-request" /> + {{ issue.mergeRequestsCount }} + </li> + <li + v-if="issue.upvotes" + v-gl-tooltip + class="gl-display-none gl-sm-display-block gl-mr-3" + :title="$options.i18n.upvotes" + data-testid="issuable-upvotes" + > + <gl-icon name="thumb-up" /> + {{ issue.upvotes }} + </li> + <li + v-if="issue.downvotes" + v-gl-tooltip + class="gl-display-none gl-sm-display-block gl-mr-3" + :title="$options.i18n.downvotes" + data-testid="issuable-downvotes" + > + <gl-icon name="thumb-down" /> + {{ issue.downvotes }} + </li> + <slot></slot> + </ul> +</template> diff --git a/app/assets/javascripts/issues/list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue index 64de4b1947b..12a83f06453 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -1,19 +1,12 @@ <script> -import { - GlButton, - GlEmptyState, - GlFilteredSearchToken, - GlIcon, - GlLink, - GlSprintf, - GlTooltipDirective, -} from '@gitlab/ui'; +import { GlButton, GlFilteredSearchToken, GlTooltipDirective } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue'; import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue'; import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql'; -import createFlash, { FLASH_TYPES } from '~/flash'; +import { createAlert, VARIANT_INFO } from '~/flash'; import { TYPE_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { ITEM_TYPE } from '~/groups/constants'; @@ -24,11 +17,11 @@ import axios from '~/lib/utils/axios_utils'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import { scrollUp } from '~/lib/utils/scroll_utils'; import { getParameterByName, joinPaths } from '~/lib/utils/url_utility'; -import { helpPagePath } from '~/helpers/help_page_helper'; import { - DEFAULT_NONE_ANY, FILTERED_SEARCH_TERM, - OPERATOR_IS_ONLY, + OPERATORS_IS, + OPERATORS_IS_NOT, + OPERATORS_IS_NOT_OR, TOKEN_TITLE_ASSIGNEE, TOKEN_TITLE_AUTHOR, TOKEN_TITLE_CONFIDENTIAL, @@ -38,9 +31,8 @@ import { TOKEN_TITLE_MY_REACTION, TOKEN_TITLE_ORGANIZATION, TOKEN_TITLE_RELEASE, + TOKEN_TITLE_SEARCH_WITHIN, TOKEN_TITLE_TYPE, - OPERATOR_IS_NOT_OR, - OPERATOR_IS_AND_IS_NOT, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, TOKEN_TYPE_CONFIDENTIAL, @@ -50,6 +42,7 @@ import { TOKEN_TYPE_MY_REACTION, TOKEN_TYPE_ORGANIZATION, TOKEN_TYPE_RELEASE, + TOKEN_TYPE_SEARCH_WITHIN, TOKEN_TYPE_TYPE, } from '~/vue_shared/components/filtered_search_bar/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; @@ -70,11 +63,9 @@ import { PARAM_SORT, PARAM_STATE, RELATIVE_POSITION_ASC, - TYPE_TOKEN_TASK_OPTION, UPDATED_DESC, urlSortParams, } from '../constants'; - import eventHub from '../eventhub'; import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql'; import searchLabelsQuery from '../queries/search_labels.query.graphql'; @@ -91,10 +82,11 @@ import { getSortOptions, isSortKey, } from '../utils'; +import EmptyStateWithAnyIssues from './empty_state_with_any_issues.vue'; +import EmptyStateWithoutAnyIssues from './empty_state_without_any_issues.vue'; import NewIssueDropdown from './new_issue_dropdown.vue'; -const AuthorToken = () => - import('~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'); +const UserToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'); const EmojiToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'); const LabelToken = () => @@ -113,13 +105,12 @@ export default { IssuableListTabs, components: { CsvImportExportButtons, + EmptyStateWithAnyIssues, + EmptyStateWithoutAnyIssues, GlButton, - GlEmptyState, - GlIcon, - GlLink, - GlSprintf, IssuableByEmail, IssuableList, + IssueCardStatistics, IssueCardTimeInfo, NewIssueDropdown, }, @@ -131,15 +122,14 @@ export default { 'autocompleteAwardEmojisPath', 'calendarPath', 'canBulkUpdate', - 'canCreateProjects', 'canReadCrmContact', 'canReadCrmOrganization', - 'emptyStateSvgPath', 'exportCsvPath', 'fullPath', 'hasAnyIssues', 'hasAnyProjects', 'hasBlockedIssuesFeature', + 'hasIssuableHealthStatusFeature', 'hasIssueWeightsFeature', 'hasScopedLabelsFeature', 'initialEmail', @@ -149,13 +139,10 @@ export default { 'isProject', 'isPublicVisibilityRestricted', 'isSignedIn', - 'jiraIntegrationPath', 'newIssuePath', - 'newProjectPath', 'releasesPath', 'rssPath', 'showNewIssueLink', - 'signInPath', ], props: { eeSearchTokens: { @@ -163,6 +150,21 @@ export default { required: false, default: () => [], }, + eeTypeTokenOptions: { + type: Array, + required: false, + default: () => [], + }, + eeWorkItemTypes: { + type: Array, + required: false, + default: () => [], + }, + eeIsOkrsEnabled: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -189,10 +191,7 @@ export default { return data[this.namespace]?.issues.nodes ?? []; }, result({ data }) { - if (!data) { - return; - } - this.pageInfo = data[this.namespace]?.issues.pageInfo ?? {}; + this.pageInfo = data?.[this.namespace]?.issues.pageInfo ?? {}; this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); }, error(error) { @@ -239,24 +238,27 @@ export default { state: this.state, ...this.pageParams, ...this.apiFilterParams, - types: this.apiFilterParams.types || defaultWorkItemTypes, + types: this.apiFilterParams.types || this.defaultWorkItemTypes, }; }, namespace() { return this.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP; }, + defaultWorkItemTypes() { + return [...defaultWorkItemTypes, ...this.eeWorkItemTypes]; + }, typeTokenOptions() { - return defaultTypeTokenOptions.concat(TYPE_TOKEN_TASK_OPTION); + return [...defaultTypeTokenOptions, ...this.eeTypeTokenOptions]; }, hasOrFeature() { return this.glFeatures.orIssuableQueries; }, hasSearch() { - return ( + return Boolean( this.searchQuery || - Object.keys(this.urlFilterParams).length || - this.pageParams.afterCursor || - this.pageParams.beforeCursor + Object.keys(this.urlFilterParams).length || + this.pageParams.afterCursor || + this.pageParams.beforeCursor, ); }, isBulkEditButtonDisabled() { @@ -284,13 +286,13 @@ export default { return convertToUrlParams(this.filterTokens); }, searchQuery() { - return convertToSearchQuery(this.filterTokens) || undefined; + return convertToSearchQuery(this.filterTokens); }, searchTokens() { - const preloadedAuthors = []; + const preloadedUsers = []; if (gon.current_user_id) { - preloadedAuthors.push({ + preloadedUsers.push({ id: convertToGraphQLId(TYPE_USER, gon.current_user_id), name: gon.current_user_fullname, username: gon.current_username, @@ -300,28 +302,41 @@ export default { const tokens = [ { + type: TOKEN_TYPE_SEARCH_WITHIN, + title: TOKEN_TITLE_SEARCH_WITHIN, + icon: 'search', + token: GlFilteredSearchToken, + unique: true, + operators: OPERATORS_IS, + options: [ + { icon: 'title', value: 'TITLE', title: this.$options.i18n.titles }, + { + icon: 'text-description', + value: 'DESCRIPTION', + title: this.$options.i18n.descriptions, + }, + ], + }, + { type: TOKEN_TYPE_AUTHOR, title: TOKEN_TITLE_AUTHOR, icon: 'pencil', - token: AuthorToken, - dataType: 'user', - unique: true, - defaultAuthors: [], - fetchAuthors: this.fetchUsers, + token: UserToken, + defaultUsers: [], + operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT, + fetchUsers: this.fetchUsers, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-author`, - preloadedAuthors, + preloadedUsers, }, { type: TOKEN_TYPE_ASSIGNEE, title: TOKEN_TITLE_ASSIGNEE, icon: 'user', - token: AuthorToken, - dataType: 'user', - defaultAuthors: DEFAULT_NONE_ANY, - operators: this.hasOrFeature ? OPERATOR_IS_NOT_OR : OPERATOR_IS_AND_IS_NOT, - fetchAuthors: this.fetchUsers, + token: UserToken, + operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT, + fetchUsers: this.fetchUsers, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-assignee`, - preloadedAuthors, + preloadedUsers, }, { type: TOKEN_TYPE_MILESTONE, @@ -337,7 +352,6 @@ export default { title: TOKEN_TITLE_LABEL, icon: 'labels', token: LabelToken, - defaultLabels: DEFAULT_NONE_ANY, fetchLabels: this.fetchLabels, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-label`, }, @@ -378,7 +392,7 @@ export default { icon: 'eye-slash', token: GlFilteredSearchToken, unique: true, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, options: [ { icon: 'eye-slash', value: 'yes', title: this.$options.i18n.confidentialYes }, { icon: 'eye', value: 'no', title: this.$options.i18n.confidentialNo }, @@ -394,9 +408,8 @@ export default { token: CrmContactToken, fullPath: this.fullPath, isProject: this.isProject, - defaultContacts: DEFAULT_NONE_ANY, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-crm-contacts`, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, unique: true, }); } @@ -409,9 +422,8 @@ export default { token: CrmOrganizationToken, fullPath: this.fullPath, isProject: this.isProject, - defaultOrganizations: DEFAULT_NONE_ANY, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-crm-organizations`, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, unique: true, }); } @@ -428,11 +440,14 @@ export default { return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage); }, showPageSizeControls() { - /** only show page size controls when the tab count is greater than the default/minimum page size control i.e 20 in this case */ return this.currentTabCount > PAGE_SIZE; }, sortOptions() { - return getSortOptions(this.hasIssueWeightsFeature, this.hasBlockedIssuesFeature); + return getSortOptions({ + hasBlockedIssuesFeature: this.hasBlockedIssuesFeature, + hasIssuableHealthStatusFeature: this.hasIssuableHealthStatusFeature, + hasIssueWeightsFeature: this.hasIssueWeightsFeature, + }); }, tabCounts() { const { openedIssues, closedIssues, allIssues } = this.issuesCounts; @@ -457,10 +472,7 @@ export default { page_before: this.pageParams.beforeCursor ?? undefined, }; }, - issuesHelpPagePath() { - return helpPagePath('user/project/issues/index'); - }, - shouldDisableSomeFilters() { + shouldDisableTextSearch() { return this.isAnonymousSearchDisabled && !this.isSignedIn; }, }, @@ -482,18 +494,17 @@ export default { eventHub.$off('issuables:toggleBulkEdit', this.toggleBulkEditSidebar); }, methods: { - fetchWithCache(path, cacheName, searchKey, search, wrapData = false) { + fetchWithCache(path, cacheName, searchKey, search) { if (this.cache[cacheName]) { const data = search ? fuzzaldrinPlus.filter(this.cache[cacheName], search, { key: searchKey }) : this.cache[cacheName].slice(0, MAX_LIST_SIZE); - return wrapData ? Promise.resolve({ data }) : Promise.resolve(data); + return Promise.resolve(data); } return axios.get(path).then(({ data }) => { this.cache[cacheName] = data; - const result = data.slice(0, MAX_LIST_SIZE); - return wrapData ? { data: result } : result; + return data.slice(0, MAX_LIST_SIZE); }); }, fetchEmojis(search) { @@ -554,14 +565,10 @@ export default { }, async handleBulkUpdateClick() { if (!this.hasInitBulkEdit) { - const bulkUpdateSidebar = await import('~/issuable/bulk_update_sidebar'); + const bulkUpdateSidebar = await import('~/issuable'); bulkUpdateSidebar.initBulkUpdateSidebar('issuable_'); - bulkUpdateSidebar.initStatusDropdown(); - bulkUpdateSidebar.initSubscriptionsDropdown(); - bulkUpdateSidebar.initMoveIssuesButton(); - const usersSelect = await import('~/users_select'); - const UsersSelect = usersSelect.default; + const UsersSelect = (await import('~/users_select')).default; new UsersSelect(); // eslint-disable-line no-new this.hasInitBulkEdit = true; @@ -570,19 +577,20 @@ export default { eventHub.$emit('issuables:enableBulkEdit'); }, handleClickTab(state) { - if (this.state !== state) { - this.pageParams = getInitialPageParams(this.pageSize); + if (this.state === state) { + return; } + this.state = state; + this.pageParams = getInitialPageParams(this.pageSize); this.$router.push({ query: this.urlParams }); }, handleDismissAlert() { this.issuesError = null; }, - handleFilter(filter) { - this.setFilterTokens(filter); - + handleFilter(tokens) { + this.setFilterTokens(tokens); this.pageParams = getInitialPageParams(this.pageSize); this.$router.push({ query: this.urlParams }); @@ -642,15 +650,17 @@ export default { }); }, handleSort(sortKey) { + if (this.sortKey === sortKey) { + return; + } + if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) { this.showIssueRepositioningMessage(); return; } - if (this.sortKey !== sortKey) { - this.pageParams = getInitialPageParams(this.pageSize); - } this.sortKey = sortKey; + this.pageParams = getInitialPageParams(this.pageSize); if (this.isSignedIn) { this.saveSortPreference(sortKey); @@ -673,49 +683,36 @@ export default { Sentry.captureException(error); }); }, - setFilterTokens(filtersArg) { - const filters = this.removeDisabledSearchTerms(filtersArg); + setFilterTokens(tokens) { + this.filterTokens = this.removeDisabledSearchTerms(tokens); - this.filterTokens = filters; - - // If we filtered something out, let's show a warning message - if (filters.length < filtersArg.length) { + if (this.filterTokens.length < tokens.length) { this.showAnonymousSearchingMessage(); } }, removeDisabledSearchTerms(filters) { - // If we shouldn't disable anything, let's return the same thing - if (!this.shouldDisableSomeFilters) { - return filters; - } - - const filtersWithoutSearchTerms = filters.filter( - (token) => !(token.type === FILTERED_SEARCH_TERM && token.value?.data), - ); - - return filtersWithoutSearchTerms; + return this.shouldDisableTextSearch + ? filters.filter((token) => !(token.type === FILTERED_SEARCH_TERM && token.value?.data)) + : filters; }, showAnonymousSearchingMessage() { - createFlash({ + createAlert({ message: this.$options.i18n.anonymousSearchingMessage, - type: FLASH_TYPES.NOTICE, + variant: VARIANT_INFO, }); }, showIssueRepositioningMessage() { - createFlash({ + createAlert({ message: this.$options.i18n.issueRepositioningMessage, - type: FLASH_TYPES.NOTICE, + variant: VARIANT_INFO, }); }, toggleBulkEditSidebar(showBulkEditSidebar) { this.showBulkEditSidebar = showBulkEditSidebar; }, handlePageSizeChange(newPageSize) { - /** make sure the page number is preserved so that the current context is not lost* */ - const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE); - const pageNumberSize = lastPageSize ? 'lastPageSize' : 'firstPageSize'; - /** depending upon what page or page size we are dynamically set pageParams * */ - this.pageParams[pageNumberSize] = newPageSize; + const pageParam = getParameterByName(PARAM_LAST_PAGE_SIZE) ? 'lastPageSize' : 'firstPageSize'; + this.pageParams[pageParam] = newPageSize; this.pageSize = newPageSize; scrollUp(); @@ -724,16 +721,14 @@ export default { updateData(sortValue) { const firstPageSize = getParameterByName(PARAM_FIRST_PAGE_SIZE); const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE); - const pageAfter = getParameterByName(PARAM_PAGE_AFTER); - const pageBefore = getParameterByName(PARAM_PAGE_BEFORE); const state = getParameterByName(PARAM_STATE); const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC; const dashboardSortKey = getSortKey(sortValue); const graphQLSortKey = isSortKey(sortValue?.toUpperCase()) && sortValue.toUpperCase(); - // The initial sort is an old enum value when it is saved on the dashboard issues page. - // The initial sort is a GraphQL enum value when it is saved on the Vue issues list page. + // The initial sort is an old enum value when it is saved on the Haml dashboard issues page. + // The initial sort is a GraphQL enum value when it is saved on the Vue group/project issues page. let sortKey = dashboardSortKey || graphQLSortKey || defaultSortKey; if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) { @@ -741,15 +736,15 @@ export default { sortKey = defaultSortKey; } - this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); this.setFilterTokens(getFilterTokens(window.location.search)); + this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); this.pageParams = getInitialPageParams( this.pageSize, isPositiveInteger(firstPageSize) ? parseInt(firstPageSize, 10) : undefined, isPositiveInteger(lastPageSize) ? parseInt(lastPageSize, 10) : undefined, - pageAfter, - pageBefore, + getParameterByName(PARAM_PAGE_AFTER), + getParameterByName(PARAM_PAGE_BEFORE), ); this.sortKey = sortKey; this.state = state || IssuableStates.Opened; @@ -827,9 +822,14 @@ export default { > {{ $options.i18n.editIssues }} </gl-button> - <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + <gl-button + v-if="showNewIssueLink && !eeIsOkrsEnabled" + :href="newIssuePath" + variant="confirm" + > {{ $options.i18n.newIssueLabel }} </gl-button> + <slot name="new-objective-button"></slot> <new-issue-dropdown v-if="showNewIssueDropdown" /> </template> @@ -842,129 +842,25 @@ export default { </template> <template #statistics="{ issuable = {} }"> - <li - v-if="issuable.mergeRequestsCount" - v-gl-tooltip - class="gl-display-none gl-sm-display-block" - :title="$options.i18n.relatedMergeRequests" - data-testid="merge-requests" - > - <gl-icon name="merge-request" /> - {{ issuable.mergeRequestsCount }} - </li> - <li - v-if="issuable.upvotes" - v-gl-tooltip - class="issuable-upvotes gl-display-none gl-sm-display-block" - :title="$options.i18n.upvotes" - data-testid="issuable-upvotes" - > - <gl-icon name="thumb-up" /> - {{ issuable.upvotes }} - </li> - <li - v-if="issuable.downvotes" - v-gl-tooltip - class="issuable-downvotes gl-display-none gl-sm-display-block" - :title="$options.i18n.downvotes" - data-testid="issuable-downvotes" - > - <gl-icon name="thumb-down" /> - {{ issuable.downvotes }} - </li> - <slot :issuable="issuable"></slot> + <issue-card-statistics :issue="issuable" /> </template> <template #empty-state> - <gl-empty-state - v-if="hasSearch" - :description="$options.i18n.noSearchResultsDescription" - :title="$options.i18n.noSearchResultsTitle" - :svg-path="emptyStateSvgPath" - > - <template #actions> - <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> - {{ $options.i18n.newIssueLabel }} - </gl-button> - </template> - </gl-empty-state> - - <gl-empty-state - v-else-if="isOpenTab" - :description="$options.i18n.noOpenIssuesDescription" - :title="$options.i18n.noOpenIssuesTitle" - :svg-path="emptyStateSvgPath" - > - <template #actions> - <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> - {{ $options.i18n.newIssueLabel }} - </gl-button> - </template> - </gl-empty-state> - - <gl-empty-state - v-else - :title="$options.i18n.noClosedIssuesTitle" - :svg-path="emptyStateSvgPath" - /> + <empty-state-with-any-issues :has-search="hasSearch" :is-open-tab="isOpenTab" /> + </template> + + <template #list-body> + <slot name="list-body"></slot> </template> </issuable-list> - <template v-else-if="isSignedIn"> - <gl-empty-state :title="$options.i18n.noIssuesSignedInTitle" :svg-path="emptyStateSvgPath"> - <template #description> - <gl-link :href="issuesHelpPagePath" target="_blank">{{ - $options.i18n.noIssuesSignedInDescription - }}</gl-link> - <p v-if="canCreateProjects"> - <strong>{{ $options.i18n.noGroupIssuesSignedInDescription }}</strong> - </p> - </template> - <template #actions> - <gl-button v-if="canCreateProjects" :href="newProjectPath" variant="confirm"> - {{ $options.i18n.newProjectLabel }} - </gl-button> - <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> - {{ $options.i18n.newIssueLabel }} - </gl-button> - <csv-import-export-buttons - v-if="showCsvButtons" - class="gl-w-full gl-sm-w-auto gl-sm-mr-3" - :export-csv-path="exportCsvPathWithQuery" - :issuable-count="currentTabCount" - /> - <new-issue-dropdown v-if="showNewIssueDropdown" class="gl-align-self-center" /> - </template> - </gl-empty-state> - <hr /> - <p class="gl-text-center gl-font-weight-bold gl-mb-0"> - {{ $options.i18n.jiraIntegrationTitle }} - </p> - <p class="gl-text-center gl-mb-0"> - <gl-sprintf :message="$options.i18n.jiraIntegrationMessage"> - <template #jiraDocsLink="{ content }"> - <gl-link :href="jiraIntegrationPath">{{ content }}</gl-link> - </template> - </gl-sprintf> - </p> - <p class="gl-text-center gl-text-gray-500"> - {{ $options.i18n.jiraIntegrationSecondaryMessage }} - </p> - </template> - - <gl-empty-state + <empty-state-without-any-issues v-else - :title="$options.i18n.noIssuesSignedOutTitle" - :svg-path="emptyStateSvgPath" - :primary-button-text="$options.i18n.noIssuesSignedOutButtonText" - :primary-button-link="signInPath" - > - <template #description> - <gl-link :href="issuesHelpPagePath" target="_blank">{{ - $options.i18n.noIssuesSignedOutDescription - }}</gl-link> - </template> - </gl-empty-state> + :current-tab-count="currentTabCount" + :export-csv-path-with-query="exportCsvPathWithQuery" + :show-csv-buttons="showCsvButtons" + :show-new-issue-dropdown="showNewIssueDropdown" + /> <issuable-by-email v-if="showIssuableByEmail" class="gl-text-center gl-pt-5 gl-pb-7" /> </div> diff --git a/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue b/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue index 666e80dfd4b..e420c21a11f 100644 --- a/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue +++ b/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue @@ -6,7 +6,7 @@ import { GlLoadingIcon, GlSearchBoxByType, } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; @@ -45,7 +45,7 @@ export default { }, update: ({ group }) => group.projects.nodes ?? [], error(error) { - createFlash({ + createAlert({ message: __('An error occurred while loading projects.'), captureError: true, error, diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index 5ed9ceea856..49a953cad43 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -6,7 +6,7 @@ import { FILTER_STARTED, FILTER_UPCOMING, OPERATOR_IS, - OPERATOR_IS_NOT, + OPERATOR_NOT, OPERATOR_OR, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, @@ -22,6 +22,7 @@ import { TOKEN_TYPE_RELEASE, TOKEN_TYPE_TYPE, TOKEN_TYPE_WEIGHT, + TOKEN_TYPE_SEARCH_WITHIN, } from '~/vue_shared/components/filtered_search_bar/constants'; import { WORK_ITEM_TYPE_ENUM_INCIDENT, @@ -30,6 +31,50 @@ import { WORK_ITEM_TYPE_ENUM_TASK, } from '~/work_items/constants'; +export const ISSUE_REFERENCE = /^#\d+$/; +export const MAX_LIST_SIZE = 10; +export const PAGE_SIZE = 20; +export const PARAM_ASSIGNEE_ID = 'assignee_id'; +export const PARAM_FIRST_PAGE_SIZE = 'first_page_size'; +export const PARAM_LAST_PAGE_SIZE = 'last_page_size'; +export const PARAM_PAGE_AFTER = 'page_after'; +export const PARAM_PAGE_BEFORE = 'page_before'; +export const PARAM_SORT = 'sort'; +export const PARAM_STATE = 'state'; +export const RELATIVE_POSITION = 'relative_position'; + +export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC'; +export const BLOCKING_ISSUES_DESC = 'BLOCKING_ISSUES_DESC'; +export const CLOSED_AT_ASC = 'CLOSED_AT_ASC'; +export const CLOSED_AT_DESC = 'CLOSED_AT_DESC'; +export const CREATED_ASC = 'CREATED_ASC'; +export const CREATED_DESC = 'CREATED_DESC'; +export const DUE_DATE_ASC = 'DUE_DATE_ASC'; +export const DUE_DATE_DESC = 'DUE_DATE_DESC'; +export const HEALTH_STATUS_ASC = 'HEALTH_STATUS_ASC'; +export const HEALTH_STATUS_DESC = 'HEALTH_STATUS_DESC'; +export const LABEL_PRIORITY_ASC = 'LABEL_PRIORITY_ASC'; +export const LABEL_PRIORITY_DESC = 'LABEL_PRIORITY_DESC'; +export const MILESTONE_DUE_ASC = 'MILESTONE_DUE_ASC'; +export const MILESTONE_DUE_DESC = 'MILESTONE_DUE_DESC'; +export const POPULARITY_ASC = 'POPULARITY_ASC'; +export const POPULARITY_DESC = 'POPULARITY_DESC'; +export const PRIORITY_ASC = 'PRIORITY_ASC'; +export const PRIORITY_DESC = 'PRIORITY_DESC'; +export const RELATIVE_POSITION_ASC = 'RELATIVE_POSITION_ASC'; +export const TITLE_ASC = 'TITLE_ASC'; +export const TITLE_DESC = 'TITLE_DESC'; +export const UPDATED_ASC = 'UPDATED_ASC'; +export const UPDATED_DESC = 'UPDATED_DESC'; +export const WEIGHT_ASC = 'WEIGHT_ASC'; +export const WEIGHT_DESC = 'WEIGHT_DESC'; + +export const API_PARAM = 'apiParam'; +export const URL_PARAM = 'urlParam'; +export const NORMAL_FILTER = 'normalFilter'; +export const SPECIAL_FILTER = 'specialFilter'; +export const ALTERNATIVE_FILTER = 'alternativeFilter'; + export const i18n = { anonymousSearchingMessage: __('You must sign in to search for specific terms.'), calendarLabel: __('Subscribe to calendar'), @@ -57,11 +102,9 @@ export const i18n = { ), noOpenIssuesDescription: __('To keep this project going, create a new issue'), noOpenIssuesTitle: __('There are no open issues'), - noIssuesSignedInDescription: __('Learn more about issues.'), - noIssuesSignedInTitle: __('Use issues to collaborate on ideas, solve problems, and plan work'), + noIssuesDescription: __('Learn more about issues.'), + noIssuesTitle: __('Use issues to collaborate on ideas, solve problems, and plan work'), noIssuesSignedOutButtonText: __('Register / Sign In'), - noIssuesSignedOutDescription: __('Learn more about issues.'), - noIssuesSignedOutTitle: __('Use issues to collaborate on ideas, solve problems, and plan work'), noSearchResultsDescription: __('To widen your search, change or remove filters above'), noSearchResultsTitle: __('Sorry, your filter produced no results'), relatedMergeRequests: __('Related merge requests'), @@ -69,45 +112,10 @@ export const i18n = { rssLabel: __('Subscribe to RSS feed'), searchPlaceholder: __('Search or filter results...'), upvotes: __('Upvotes'), + titles: __('Titles'), + descriptions: __('Descriptions'), }; -export const ISSUE_REFERENCE = /^#\d+$/; -export const MAX_LIST_SIZE = 10; -export const PAGE_SIZE = 20; -export const PAGE_SIZE_MANUAL = 100; -export const PARAM_ASSIGNEE_ID = 'assignee_id'; -export const PARAM_FIRST_PAGE_SIZE = 'first_page_size'; -export const PARAM_LAST_PAGE_SIZE = 'last_page_size'; -export const PARAM_PAGE_AFTER = 'page_after'; -export const PARAM_PAGE_BEFORE = 'page_before'; -export const PARAM_SORT = 'sort'; -export const PARAM_STATE = 'state'; -export const RELATIVE_POSITION = 'relative_position'; - -export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC'; -export const BLOCKING_ISSUES_DESC = 'BLOCKING_ISSUES_DESC'; -export const CREATED_ASC = 'CREATED_ASC'; -export const CREATED_DESC = 'CREATED_DESC'; -export const DUE_DATE_ASC = 'DUE_DATE_ASC'; -export const DUE_DATE_DESC = 'DUE_DATE_DESC'; -export const LABEL_PRIORITY_ASC = 'LABEL_PRIORITY_ASC'; -export const LABEL_PRIORITY_DESC = 'LABEL_PRIORITY_DESC'; -export const MILESTONE_DUE_ASC = 'MILESTONE_DUE_ASC'; -export const MILESTONE_DUE_DESC = 'MILESTONE_DUE_DESC'; -export const POPULARITY_ASC = 'POPULARITY_ASC'; -export const POPULARITY_DESC = 'POPULARITY_DESC'; -export const PRIORITY_ASC = 'PRIORITY_ASC'; -export const PRIORITY_DESC = 'PRIORITY_DESC'; -export const RELATIVE_POSITION_ASC = 'RELATIVE_POSITION_ASC'; -export const TITLE_ASC = 'TITLE_ASC'; -export const TITLE_DESC = 'TITLE_DESC'; -export const UPDATED_ASC = 'UPDATED_ASC'; -export const UPDATED_DESC = 'UPDATED_DESC'; -export const WEIGHT_ASC = 'WEIGHT_ASC'; -export const WEIGHT_DESC = 'WEIGHT_DESC'; -export const CLOSED_ASC = 'CLOSED_AT_ASC'; -export const CLOSED_DESC = 'CLOSED_AT_DESC'; - export const urlSortParams = { [PRIORITY_ASC]: 'priority', [PRIORITY_DESC]: 'priority_desc', @@ -115,8 +123,8 @@ export const urlSortParams = { [CREATED_DESC]: 'created_date', [UPDATED_ASC]: 'updated_asc', [UPDATED_DESC]: 'updated_desc', - [CLOSED_ASC]: 'closed_asc', - [CLOSED_DESC]: 'closed_desc', + [CLOSED_AT_ASC]: 'closed_at', + [CLOSED_AT_DESC]: 'closed_at_desc', [MILESTONE_DUE_ASC]: 'milestone', [MILESTONE_DUE_DESC]: 'milestone_due_desc', [DUE_DATE_ASC]: 'due_date', @@ -126,20 +134,16 @@ export const urlSortParams = { [LABEL_PRIORITY_ASC]: 'label_priority', [LABEL_PRIORITY_DESC]: 'label_priority_desc', [RELATIVE_POSITION_ASC]: RELATIVE_POSITION, + [TITLE_ASC]: 'title_asc', + [TITLE_DESC]: 'title_desc', + [HEALTH_STATUS_ASC]: 'health_status_asc', + [HEALTH_STATUS_DESC]: 'health_status_desc', [WEIGHT_ASC]: 'weight', [WEIGHT_DESC]: 'weight_desc', [BLOCKING_ISSUES_ASC]: 'blocking_issues_asc', [BLOCKING_ISSUES_DESC]: 'blocking_issues_desc', - [TITLE_ASC]: 'title_asc', - [TITLE_DESC]: 'title_desc', }; -export const API_PARAM = 'apiParam'; -export const URL_PARAM = 'urlParam'; -export const NORMAL_FILTER = 'normalFilter'; -export const SPECIAL_FILTER = 'specialFilter'; -export const ALTERNATIVE_FILTER = 'alternativeFilter'; - export const specialFilterValues = [ FILTER_NONE, FILTER_ANY, @@ -148,7 +152,17 @@ export const specialFilterValues = [ FILTER_STARTED, ]; -export const TYPE_TOKEN_TASK_OPTION = { icon: 'issue-type-task', title: 'task', value: 'task' }; +export const TYPE_TOKEN_OBJECTIVE_OPTION = { + icon: 'issue-type-objective', + title: 'objective', + value: 'objective', +}; + +export const TYPE_TOKEN_KEY_RESULT_OPTION = { + icon: 'issue-type-key-result', + title: 'key_result', + value: 'key_result', +}; // This should be consistent with Issue::TYPES_FOR_LIST in the backend // https://gitlab.com/gitlab-org/gitlab/-/blob/1379c2d7bffe2a8d809f23ac5ef9b4114f789c07/app/models/issue.rb#L48 @@ -163,20 +177,35 @@ export const defaultTypeTokenOptions = [ { icon: 'issue-type-issue', title: 'issue', value: 'issue' }, { icon: 'issue-type-incident', title: 'incident', value: 'incident' }, { icon: 'issue-type-test-case', title: 'test_case', value: 'test_case' }, + { icon: 'issue-type-task', title: 'task', value: 'task' }, ]; export const filters = { [TOKEN_TYPE_AUTHOR]: { [API_PARAM]: { [NORMAL_FILTER]: 'authorUsername', + [ALTERNATIVE_FILTER]: 'authorUsernames', }, [URL_PARAM]: { [OPERATOR_IS]: { [NORMAL_FILTER]: 'author_username', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[author_username]', }, + [OPERATOR_OR]: { + [ALTERNATIVE_FILTER]: 'or[author_username]', + }, + }, + }, + [TOKEN_TYPE_SEARCH_WITHIN]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'in', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'in', + }, }, }, [TOKEN_TYPE_ASSIGNEE]: { @@ -190,7 +219,7 @@ export const filters = { [SPECIAL_FILTER]: 'assignee_id', [ALTERNATIVE_FILTER]: 'assignee_username', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[assignee_username][]', }, [OPERATOR_OR]: { @@ -208,7 +237,7 @@ export const filters = { [NORMAL_FILTER]: 'milestone_title', [SPECIAL_FILTER]: 'milestone_title', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[milestone_title]', [SPECIAL_FILTER]: 'not[milestone_title]', }, @@ -225,7 +254,7 @@ export const filters = { [SPECIAL_FILTER]: 'label_name[]', [ALTERNATIVE_FILTER]: 'label_name', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[label_name][]', }, }, @@ -238,7 +267,7 @@ export const filters = { [OPERATOR_IS]: { [NORMAL_FILTER]: 'type[]', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[type][]', }, }, @@ -253,7 +282,7 @@ export const filters = { [NORMAL_FILTER]: 'release_tag', [SPECIAL_FILTER]: 'release_tag', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[release_tag]', }, }, @@ -268,7 +297,7 @@ export const filters = { [NORMAL_FILTER]: 'my_reaction_emoji', [SPECIAL_FILTER]: 'my_reaction_emoji', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[my_reaction_emoji]', }, }, @@ -293,7 +322,7 @@ export const filters = { [NORMAL_FILTER]: 'iteration_id', [SPECIAL_FILTER]: 'iteration_id', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[iteration_id]', [SPECIAL_FILTER]: 'not[iteration_id]', }, @@ -309,7 +338,7 @@ export const filters = { [NORMAL_FILTER]: 'epic_id', [SPECIAL_FILTER]: 'epic_id', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[epic_id]', }, }, @@ -324,7 +353,7 @@ export const filters = { [NORMAL_FILTER]: 'weight', [SPECIAL_FILTER]: 'weight', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[weight]', }, }, diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js index 5e04dd1971c..7b68b7432c9 100644 --- a/app/assets/javascripts/issues/list/index.js +++ b/app/assets/javascripts/issues/list/index.js @@ -2,16 +2,15 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import VueRouter from 'vue-router'; import IssuesListApp from 'ee_else_ce/issues/list/components/issues_list_app.vue'; -import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; -import JiraIssuesImportStatusRoot from './components/jira_issues_import_status_app.vue'; +import JiraIssuesImportStatusApp from './components/jira_issues_import_status_app.vue'; import { gqlClient } from './graphql'; export function mountJiraIssuesListApp() { - const el = document.querySelector('.js-jira-issues-import-status'); + const el = document.querySelector('.js-jira-issues-import-status-root'); if (!el) { - return false; + return null; } const { issuesPath, projectPath } = el.dataset; @@ -19,21 +18,19 @@ export function mountJiraIssuesListApp() { const isJiraConfigured = parseBoolean(el.dataset.isJiraConfigured); if (!isJiraConfigured || !canEdit) { - return false; + return null; } Vue.use(VueApollo); - const defaultClient = createDefaultClient(); - const apolloProvider = new VueApollo({ - defaultClient, - }); return new Vue({ el, name: 'JiraIssuesImportStatusRoot', - apolloProvider, + apolloProvider: new VueApollo({ + defaultClient: gqlClient, + }), render(createComponent) { - return createComponent(JiraIssuesImportStatusRoot, { + return createComponent(JiraIssuesImportStatusApp, { props: { canEdit, isJiraConfigured, @@ -46,10 +43,10 @@ export function mountJiraIssuesListApp() { } export function mountIssuesListApp() { - const el = document.querySelector('.js-issues-list'); + const el = document.querySelector('.js-issues-list-root'); if (!el) { - return false; + return null; } Vue.use(VueApollo); @@ -77,6 +74,7 @@ export function mountIssuesListApp() { hasIssueWeightsFeature, hasIterationsFeature, hasScopedLabelsFeature, + hasOkrsFeature, importCsvIssuesPath, initialEmail, initialSort, @@ -127,6 +125,7 @@ export function mountIssuesListApp() { hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), hasIterationsFeature: parseBoolean(hasIterationsFeature), hasScopedLabelsFeature: parseBoolean(hasScopedLabelsFeature), + hasOkrsFeature: parseBoolean(hasOkrsFeature), initialSort, isAnonymousSearchDisabled: parseBoolean(isAnonymousSearchDisabled), isIssueRepositioningDisabled: parseBoolean(isIssueRepositioningDisabled), diff --git a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql index b447289b425..ee97fb6edca 100644 --- a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql @@ -10,6 +10,7 @@ query getIssues( $search: String $sort: IssueSort $state: IssuableState + $in: [IssuableSearchableField!] $assigneeId: String $assigneeUsernames: [String!] $authorUsername: String @@ -38,6 +39,7 @@ query getIssues( search: $search sort: $sort state: $state + in: $in assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames authorUsername: $authorUsername @@ -72,6 +74,7 @@ query getIssues( search: $search sort: $sort state: $state + in: $in assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames authorUsername: $authorUsername diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js index 2f9ab9d62ee..b566e08731c 100644 --- a/app/assets/javascripts/issues/list/utils.js +++ b/app/assets/javascripts/issues/list/utils.js @@ -4,9 +4,10 @@ import { getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import { FILTERED_SEARCH_TERM, - OPERATOR_IS_NOT, + OPERATOR_NOT, OPERATOR_OR, TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, TOKEN_TYPE_CONFIDENTIAL, TOKEN_TYPE_ITERATION, TOKEN_TYPE_MILESTONE, @@ -14,14 +15,19 @@ import { TOKEN_TYPE_TYPE, } from '~/vue_shared/components/filtered_search_bar/constants'; import { + ALTERNATIVE_FILTER, API_PARAM, BLOCKING_ISSUES_ASC, BLOCKING_ISSUES_DESC, + CLOSED_AT_ASC, + CLOSED_AT_DESC, CREATED_ASC, CREATED_DESC, DUE_DATE_ASC, DUE_DATE_DESC, filters, + HEALTH_STATUS_ASC, + HEALTH_STATUS_DESC, LABEL_PRIORITY_ASC, LABEL_PRIORITY_DESC, MILESTONE_DUE_ASC, @@ -44,8 +50,6 @@ import { urlSortParams, WEIGHT_ASC, WEIGHT_DESC, - CLOSED_ASC, - CLOSED_DESC, } from './constants'; export const getInitialPageParams = ( @@ -66,7 +70,11 @@ export const getSortKey = (sort) => export const isSortKey = (sort) => Object.keys(urlSortParams).includes(sort); -export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) => { +export const getSortOptions = ({ + hasBlockedIssuesFeature, + hasIssuableHealthStatusFeature, + hasIssueWeightsFeature, +}) => { const sortOptions = [ { id: 1, @@ -96,8 +104,8 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) id: 4, title: __('Closed date'), sortDirection: { - ascending: CLOSED_ASC, - descending: CLOSED_DESC, + ascending: CLOSED_AT_ASC, + descending: CLOSED_AT_DESC, }, }, { @@ -150,6 +158,17 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) }, ]; + if (hasIssuableHealthStatusFeature) { + sortOptions.push({ + id: sortOptions.length + 1, + title: __('Health'), + sortDirection: { + ascending: HEALTH_STATUS_ASC, + descending: HEALTH_STATUS_DESC, + }, + }); + } + if (hasIssueWeightsFeature) { sortOptions.push({ id: sortOptions.length + 1, @@ -223,13 +242,24 @@ export const getFilterTokens = (locationSearch) => { return tokens.length ? tokens : [createTerm()]; }; -const getFilterType = (data, tokenType = '') => { +const isSpecialFilter = (type, data) => { const isAssigneeIdParam = - tokenType === TOKEN_TYPE_ASSIGNEE && + type === TOKEN_TYPE_ASSIGNEE && isPositiveInteger(data) && getParameterByName(PARAM_ASSIGNEE_ID) === data; + return specialFilterValues.includes(data) || isAssigneeIdParam; +}; + +const getFilterType = ({ type, value: { data, operator } }) => { + const isUnionedAuthor = type === TOKEN_TYPE_AUTHOR && operator === OPERATOR_OR; - return specialFilterValues.includes(data) || isAssigneeIdParam ? SPECIAL_FILTER : NORMAL_FILTER; + if (isUnionedAuthor) { + return ALTERNATIVE_FILTER; + } + if (isSpecialFilter(type, data)) { + return SPECIAL_FILTER; + } + return NORMAL_FILTER; }; const wildcardTokens = [TOKEN_TYPE_ITERATION, TOKEN_TYPE_MILESTONE, TOKEN_TYPE_RELEASE]; @@ -258,10 +288,10 @@ export const convertToApiParams = (filterTokens) => { filterTokens .filter((token) => token.type !== FILTERED_SEARCH_TERM) .forEach((token) => { - const filterType = getFilterType(token.value.data, token.type); - const field = filters[token.type][API_PARAM][filterType]; + const filterType = getFilterType(token); + const apiField = filters[token.type][API_PARAM][filterType]; let obj; - if (token.value.operator === OPERATOR_IS_NOT) { + if (token.value.operator === OPERATOR_NOT) { obj = not; } else if (token.value.operator === OPERATOR_OR) { obj = or; @@ -270,7 +300,7 @@ export const convertToApiParams = (filterTokens) => { } const data = formatData(token); Object.assign(obj, { - [field]: obj[field] ? [obj[field], data].flat() : data, + [apiField]: obj[apiField] ? [obj[apiField], data].flat() : data, }); }); @@ -289,10 +319,10 @@ export const convertToUrlParams = (filterTokens) => filterTokens .filter((token) => token.type !== FILTERED_SEARCH_TERM) .reduce((acc, token) => { - const filterType = getFilterType(token.value.data, token.type); - const param = filters[token.type][URL_PARAM][token.value.operator]?.[filterType]; + const filterType = getFilterType(token); + const urlParam = filters[token.type][URL_PARAM][token.value.operator]?.[filterType]; return Object.assign(acc, { - [param]: acc[param] ? [acc[param], token.value.data].flat() : token.value.data, + [urlParam]: acc[urlParam] ? [acc[urlParam], token.value.data].flat() : token.value.data, }); }, {}); @@ -300,4 +330,4 @@ export const convertToSearchQuery = (filterTokens) => filterTokens .filter((token) => token.type === FILTERED_SEARCH_TERM && token.value.data) .map((token) => token.value.data) - .join(' '); + .join(' ') || undefined; diff --git a/app/assets/javascripts/issues/manual_ordering.js b/app/assets/javascripts/issues/manual_ordering.js index bc1cffef943..1bb53dfd50d 100644 --- a/app/assets/javascripts/issues/manual_ordering.js +++ b/app/assets/javascripts/issues/manual_ordering.js @@ -1,5 +1,5 @@ import Sortable from 'sortablejs'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; import { getSortableDefaultOptions, sortableStart } from '~/sortable/utils'; @@ -11,7 +11,7 @@ const updateIssue = (url, { move_before_id, move_after_id }) => move_after_id, }) .catch(() => { - createFlash({ + createAlert({ message: s__("ManualOrdering|Couldn't save the order of the issues"), }); }); diff --git a/app/assets/javascripts/issues/related_merge_requests/store/actions.js b/app/assets/javascripts/issues/related_merge_requests/store/actions.js index 94abb50de89..4c81f1d9bc1 100644 --- a/app/assets/javascripts/issues/related_merge_requests/store/actions.js +++ b/app/assets/javascripts/issues/related_merge_requests/store/actions.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { normalizeHeaders } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; @@ -29,7 +29,7 @@ export const fetchMergeRequests = ({ state, dispatch }) => { }) .catch(() => { dispatch('receiveDataError'); - createFlash({ + createAlert({ message: __('Something went wrong while fetching related merge requests.'), }); }); diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index 0daf77e03dc..e5428f87095 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlBadge, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui'; import Visibility from 'visibilityjs'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { IssuableStatus, IssuableStatusText, @@ -327,7 +327,7 @@ export default { this.store.updateState(data); }) .catch(() => { - createFlash({ + createAlert({ message: this.defaultErrorMessage, }); }); @@ -362,7 +362,7 @@ export default { this.updateAndShowForm(res.data); }) .catch(() => { - createFlash({ + createAlert({ message: this.defaultErrorMessage, }); this.updateAndShowForm(); @@ -429,7 +429,7 @@ export default { errMsg += `. ${message}`; } - this.flashContainer = createFlash({ + this.flashContainer = createAlert({ message: errMsg, }); }) diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index 5c2a154362f..78e729b97da 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -1,11 +1,12 @@ <script> -import { GlSafeHtmlDirective as SafeHtml, GlToast, GlTooltip, GlModalDirective } from '@gitlab/ui'; +import { GlToast, GlTooltip, GlModalDirective } from '@gitlab/ui'; import $ from 'jquery'; import Sortable from 'sortablejs'; import Vue from 'vue'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { IssuableType } from '~/issues/constants'; import { isMetaKey } from '~/lib/utils/common_utils'; import { isPositiveInteger } from '~/lib/utils/number_utils'; @@ -27,6 +28,7 @@ import { TASK_TYPE_NAME, WIDGET_TYPE_DESCRIPTION, } from '~/work_items/constants'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import animateMixin from '../mixins/animate'; import { convertDescriptionWithNewSort } from '../utils'; @@ -165,7 +167,7 @@ export default { this.renderGFM(); this.updateTaskStatusText(); - if (this.workItemId) { + if (this.workItemId && this.workItemsEnabled) { const taskLink = this.$el.querySelector( `.gfm-issue[data-issue="${getIdFromGraphQLId(this.workItemId)}"]`, ); @@ -177,7 +179,7 @@ export default { }, methods: { renderGFM() { - $(this.$refs['gfm-content']).renderGFM(); + renderGFM(this.$refs['gfm-content']); if (this.canUpdate) { // eslint-disable-next-line no-new @@ -283,7 +285,7 @@ export default { }, taskListUpdateError() { - createFlash({ + createAlert({ message: sprintf( __( 'Someone edited this %{issueType} at the same time you did. The description has been updated and you will need to make your changes again.', @@ -467,7 +469,7 @@ export default { this.workItemId = newWorkItem.id; this.openWorkItemDetailModal(el); } catch (error) { - createFlash({ + createAlert({ message: sprintfWorkItem(I18N_WORK_ITEM_ERROR_CREATING, workItemTypes.TASK), error, captureError: true, diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue index 180dea77003..04c5007dbec 100644 --- a/app/assets/javascripts/issues/show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue @@ -67,6 +67,7 @@ export default { :quick-actions-docs-path="quickActionsDocsPath" :enable-autocomplete="enableAutocomplete" supports-quick-actions + use-bottom-toolbar autofocus @input="$emit('input', $event)" @keydown.meta.enter="updateIssuable" diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue index 0c6b61fb893..b56c91d7983 100644 --- a/app/assets/javascripts/issues/show/components/form.vue +++ b/app/assets/javascripts/issues/show/components/form.vue @@ -164,7 +164,7 @@ export default { <template> <form data-testid="issuable-form"> - <locked-warning v-if="showLockedWarning" /> + <locked-warning v-if="showLockedWarning" :issuable-type="issuableType" /> <gl-alert v-if="showOutdatedDescriptionWarning" class="gl-mb-5" diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index c01de63ced9..983e2e6530e 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -10,7 +10,7 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; -import createFlash, { FLASH_TYPES } from '~/flash'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import { IssuableStatus, IssueType } from '~/issues/constants'; import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants'; @@ -40,6 +40,7 @@ export default { promoteSuccessMessage: __( 'The issue was successfully promoted to an epic. Redirecting to epic...', ), + reportAbuse: __('Report abuse to administrator'), }, components: { DeleteIssueModal, @@ -191,7 +192,7 @@ export default { // Dispatch event which updates open/close state, shared among the issue show page document.dispatchEvent(new CustomEvent(EVENT_ISSUABLE_VUE_APP_CHANGE, payload)); }) - .catch(() => createFlash({ message: __('Error occurred while updating the issue status') })) + .catch(() => createAlert({ message: __('Error occurred while updating the issue status') })) .finally(() => { this.toggleStateButtonLoading(false); }); @@ -214,14 +215,14 @@ export default { throw new Error(); } - createFlash({ + createAlert({ message: this.$options.i18n.promoteSuccessMessage, - type: FLASH_TYPES.SUCCESS, + variant: VARIANT_SUCCESS, }); visitUrl(data.promoteToEpic.epic.webPath); }) - .catch(() => createFlash({ message: this.$options.i18n.promoteErrorMessage })) + .catch(() => createAlert({ message: this.$options.i18n.promoteErrorMessage })) .finally(() => { this.toggleStateButtonLoading(false); }); @@ -255,7 +256,7 @@ export default { {{ __('Promote to epic') }} </gl-dropdown-item> <gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath"> - {{ __('Report abuse') }} + {{ $options.i18n.reportAbuse }} </gl-dropdown-item> <gl-dropdown-item v-if="canReportSpam" @@ -314,7 +315,7 @@ export default { {{ __('Promote to epic') }} </gl-dropdown-item> <gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath"> - {{ __('Report abuse') }} + {{ $options.i18n.reportAbuse }} </gl-dropdown-item> <gl-dropdown-item v-if="canReportSpam" diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js index db846009409..22db19610c1 100644 --- a/app/assets/javascripts/issues/show/components/incidents/constants.js +++ b/app/assets/javascripts/issues/show/components/incidents/constants.js @@ -14,6 +14,7 @@ export const timelineFormI18n = Object.freeze({ areaPlaceholder: s__('Incident|Timeline text...'), save: __('Save'), cancel: __('Cancel'), + delete: __('Delete'), description: __('Description'), hint: __('You can enter up to 280 characters'), textRemaining: (count) => n__('%d character remaining', '%d characters remaining', count), diff --git a/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue index 60fa8cb949b..8cdd62ca9ef 100644 --- a/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue +++ b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue @@ -40,8 +40,10 @@ export default { :is-event-processed="editTimelineEventActive" :previous-occurred-at="event.occurredAt" :previous-note="event.note" + show-delete @save-event="saveEvent" @cancel="$emit('hide-edit')" + @delete="$emit('delete')" /> </div> </template> diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql index f1fc27dcb2a..4a8786b04b1 100644 --- a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql +++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql @@ -7,6 +7,12 @@ mutation CreateTimelineEvent($input: TimelineEventCreateInput!) { action occurredAt createdAt + timelineEventTags { + nodes { + id + name + } + } } errors } diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql index d88633f2ae9..e057267b006 100644 --- a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql +++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql @@ -4,6 +4,7 @@ query getAlert($iid: String!, $fullPath: ID!) { issue(iid: $iid) { id alertManagementAlert { + id iid title detailsUrl diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql index bc4e8414bfc..baeb81745ab 100644 --- a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql +++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql @@ -9,6 +9,12 @@ query GetTimelineEvents($fullPath: ID!, $incidentId: IssueID!) { action occurredAt createdAt + timelineEventTags { + nodes { + id + name + } + } } } } diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue index 5725d0f8d6a..53956fcb4b2 100644 --- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue +++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue @@ -1,16 +1,29 @@ <script> import { GlTab, GlTabs } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { trackIncidentDetailsViewsOptions } from '~/incidents/constants'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import DescriptionComponent from '../description.vue'; import getAlert from './graphql/queries/get_alert.graphql'; import HighlightBar from './highlight_bar.vue'; import TimelineTab from './timeline_events_tab.vue'; +export const incidentTabsI18n = Object.freeze({ + summaryTitle: s__('Incident|Summary'), + metricsTitle: s__('Incident|Metrics'), + alertsTitle: s__('Incident|Alert details'), + timelineTitle: s__('Incident|Timeline'), +}); + +export const TAB_NAMES = Object.freeze({ + SUMMARY: '', + ALERTS: 'alerts', + METRICS: 'metrics', + TIMELINE: 'timeline', +}); + export default { components: { AlertDetailsTable, @@ -22,8 +35,8 @@ export default { IncidentMetricTab: () => import('ee_component/issues/show/components/incidents/incident_metric_tab.vue'), }, - mixins: [glFeatureFlagsMixin()], - inject: ['fullPath', 'iid'], + inject: ['fullPath', 'iid', 'uploadMetricsFeatureAvailable'], + i18n: incidentTabsI18n, apollo: { alert: { query: getAlert, @@ -37,7 +50,7 @@ export default { return data?.project?.issue?.alertManagementAlert; }, error() { - createFlash({ + createAlert({ message: s__('Incident|There was an issue loading alert data. Please try again.'), }); }, @@ -46,12 +59,44 @@ export default { data() { return { alert: null, + activeTabIndex: 0, }; }, computed: { loading() { return this.$apollo.queries.alert.loading; }, + tabMapping() { + const availableTabs = [TAB_NAMES.SUMMARY]; + + if (this.uploadMetricsFeatureAvailable) { + availableTabs.push(TAB_NAMES.METRICS); + } + if (this.alert) { + availableTabs.push(TAB_NAMES.ALERTS); + } + + availableTabs.push(TAB_NAMES.TIMELINE); + + const tabNamesToIndex = {}; + const tabIndexToName = {}; + + availableTabs.forEach((item, index) => { + tabNamesToIndex[item] = index; + tabIndexToName[index] = item; + }); + + return { tabNamesToIndex, tabIndexToName }; + }, + currentTabIndex: { + get() { + return this.activeTabIndex; + }, + set(index) { + this.handleTabChange(index); + this.activeTabIndex = index; + }, + }, }, mounted() { this.trackPageViews(); @@ -91,25 +136,33 @@ export default { <template> <div> <gl-tabs + v-model="currentTabIndex" content-class="gl-reset-line-height" class="gl-mt-n3" data-testid="incident-tabs" - @input="handleTabChange" > - <gl-tab :title="s__('Incident|Summary')"> + <gl-tab :title="$options.i18n.summaryTitle" data-testid="summary-tab"> <highlight-bar :alert="alert" /> <description-component v-bind="$attrs" v-on="$listeners" /> </gl-tab> - <incident-metric-tab /> + <gl-tab + v-if="uploadMetricsFeatureAvailable" + :title="$options.i18n.metricsTitle" + data-testid="metrics-tab" + > + <incident-metric-tab /> + </gl-tab> <gl-tab v-if="alert" class="alert-management-details" - :title="s__('Incident|Alert details')" + :title="$options.i18n.alertsTitle" data-testid="alert-details-tab" > <alert-details-table :alert="alert" :loading="loading" /> </gl-tab> - <timeline-tab /> + <gl-tab :title="$options.i18n.timelineTitle" data-testid="timeline-tab"> + <timeline-tab /> + </gl-tab> </gl-tabs> </div> </template> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue index 72dfccca467..f1a3aebc990 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue @@ -1,7 +1,6 @@ <script> import { GlDatepicker, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; -import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; import { MAX_TEXT_LENGTH, timelineFormI18n } from './constants'; import { getUtcShiftedDate } from './utils'; @@ -27,15 +26,17 @@ export default { }, i18n: timelineFormI18n, MAX_TEXT_LENGTH, - directives: { - autofocusonshow, - }, props: { showSaveAndAdd: { type: Boolean, required: false, default: false, }, + showDelete: { + type: Boolean, + required: false, + default: false, + }, isEventProcessed: { type: Boolean, required: true, @@ -97,7 +98,7 @@ export default { this.timelineText = ''; }, focusDate() { - this.$refs.datepicker.$el.querySelector('input').focus(); + this.$refs.datepicker.$el.querySelector('input')?.focus(); }, handleSave(addAnotherEvent) { const event = { @@ -185,32 +186,42 @@ export default { </gl-form-group> </div> <gl-form-group class="gl-mb-0"> - <gl-button - variant="confirm" - category="primary" - class="gl-mr-3" - data-testid="save-button" - :disabled="!isTimelineTextValid" - :loading="isEventProcessed" - @click="handleSave(false)" - > - {{ $options.i18n.save }} - </gl-button> - <gl-button - v-if="showSaveAndAdd" - variant="confirm" - category="secondary" - class="gl-mr-3 gl-ml-n2" - data-testid="save-and-add-button" - :disabled="!isTimelineTextValid" - :loading="isEventProcessed" - @click="handleSave(true)" - > - {{ $options.i18n.saveAndAdd }} - </gl-button> - <gl-button class="gl-ml-n2" :disabled="isEventProcessed" @click="$emit('cancel')"> - {{ $options.i18n.cancel }} - </gl-button> + <div class="gl-display-flex"> + <gl-button + variant="confirm" + category="primary" + class="gl-mr-3" + data-testid="save-button" + :disabled="!isTimelineTextValid" + :loading="isEventProcessed" + @click="handleSave(false)" + > + {{ $options.i18n.save }} + </gl-button> + <gl-button + v-if="showSaveAndAdd" + variant="confirm" + category="secondary" + class="gl-mr-3 gl-ml-n2" + data-testid="save-and-add-button" + :disabled="!isTimelineTextValid" + :loading="isEventProcessed" + @click="handleSave(true)" + > + {{ $options.i18n.saveAndAdd }} + </gl-button> + <gl-button class="gl-ml-n2" :disabled="isEventProcessed" @click="$emit('cancel')"> + {{ $options.i18n.cancel }} + </gl-button> + <gl-button + v-if="showDelete" + class="gl-ml-auto btn-danger" + :disabled="isEventProcessed" + @click="$emit('delete')" + > + {{ $options.i18n.delete }} + </gl-button> + </div> <div class="timeline-event-bottom-border"></div> </gl-form-group> </form> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue index cbf3c387fa3..90ee4351e39 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue @@ -1,5 +1,6 @@ <script> -import { GlDropdown, GlDropdownItem, GlIcon, GlSafeHtmlDirective, GlSprintf } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf, GlBadge } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { formatDate } from '~/lib/utils/datetime_utility'; import { timelineItemI18n } from './constants'; import { getEventIcon } from './utils'; @@ -12,9 +13,10 @@ export default { GlDropdownItem, GlIcon, GlSprintf, + GlBadge, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, inject: ['canUpdateTimelineEvent'], props: { @@ -30,6 +32,11 @@ export default { type: String, required: true, }, + eventTag: { + type: String, + required: false, + default: null, + }, }, computed: { time() { @@ -42,41 +49,41 @@ export default { }; </script> <template> - <div class="gl-display-flex gl-align-items-start"> + <div class="timeline-event gl-display-grid"> <div class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1" > <gl-icon :name="getEventIcon(action)" class="note-icon" /> </div> - <div - class="timeline-event-note timeline-event-border gl-w-full gl-display-flex gl-flex-direction-row" - data-testid="event-text-container" - > - <div> + <div class="timeline-event-note timeline-event-border" data-testid="event-text-container"> + <div class="gl-display-flex gl-align-items-center gl-mb-3"> <strong class="gl-font-lg" data-testid="event-time"> <gl-sprintf :message="$options.i18n.timeUTC"> <template #time>{{ time }}</template> </gl-sprintf> </strong> - <div v-safe-html="noteHtml"></div> + <gl-badge v-if="eventTag" variant="muted" icon="tag" class="gl-ml-3"> + {{ eventTag }} + </gl-badge> </div> - <gl-dropdown - v-if="canUpdateTimelineEvent" - right - class="event-note-actions gl-ml-auto gl-align-self-start" - icon="ellipsis_v" - text-sr-only - :text="$options.i18n.moreActions" - category="tertiary" - no-caret - > - <gl-dropdown-item @click="$emit('edit')"> - {{ $options.i18n.edit }} - </gl-dropdown-item> - <gl-dropdown-item @click="$emit('delete')"> - {{ $options.i18n.delete }} - </gl-dropdown-item> - </gl-dropdown> + <div v-safe-html="noteHtml" class="md"></div> </div> + <gl-dropdown + v-if="canUpdateTimelineEvent" + right + class="event-note-actions gl-ml-auto gl-align-self-start" + icon="ellipsis_v" + text-sr-only + :text="$options.i18n.moreActions" + category="tertiary" + no-caret + > + <gl-dropdown-item @click="$emit('edit')"> + {{ $options.i18n.edit }} + </gl-dropdown-item> + <gl-dropdown-item @click="$emit('delete')"> + {{ $options.i18n.delete }} + </gl-dropdown-item> + </gl-dropdown> </div> </template> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue index 321b7ccc14a..c6b93201c97 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue @@ -50,6 +50,9 @@ export default { }, }, methods: { + getFirstTag(eventTag) { + return eventTag.nodes?.[0]?.name; + }, handleEditSelection(event) { this.eventToEdit = event.id; this.$emit('hide-new-incident-timeline-event-form'); @@ -153,6 +156,7 @@ export default { :edit-timeline-event-active="editTimelineEventActive" @handle-save-edit="handleSaveEdit" @hide-edit="hideEdit()" + @delete="handleDelete(event)" /> <incident-timeline-event-item v-else @@ -160,6 +164,7 @@ export default { :action="event.action" :occurred-at="event.occurredAt" :note-html="event.noteHtml" + :event-tag="getFirstTag(event.timelineEventTags)" @delete="handleDelete(event)" @edit="handleEditSelection(event)" /> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue index 5f70d9acac9..c8237766505 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlEmptyState, GlLoadingIcon, GlTab } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPE_ISSUE } from '~/graphql_shared/constants'; import { fetchPolicies } from '~/lib/graphql'; @@ -15,7 +15,6 @@ export default { GlButton, GlEmptyState, GlLoadingIcon, - GlTab, CreateTimelineEvent, IncidentTimelineEventsList, }, @@ -77,7 +76,7 @@ export default { </script> <template> - <gl-tab :title="$options.i18n.title"> + <div> <gl-loading-icon v-if="timelineEventLoading" size="lg" color="dark" class="gl-mt-5" /> <gl-empty-state v-else-if="showEmptyState" @@ -106,5 +105,5 @@ export default { > {{ $options.i18n.addEventButton }} </gl-button> - </gl-tab> + </div> </template> diff --git a/app/assets/javascripts/issues/show/components/locked_warning.vue b/app/assets/javascripts/issues/show/components/locked_warning.vue index 12feacb027b..4414e693ed0 100644 --- a/app/assets/javascripts/issues/show/components/locked_warning.vue +++ b/app/assets/javascripts/issues/show/components/locked_warning.vue @@ -1,29 +1,44 @@ <script> import { GlSprintf, GlLink, GlAlert } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; +import { IssuableType } from '~/issues/constants'; -const alertMessage = __( - 'Someone edited the issue at the same time you did. Please check out %{linkStart}the issue%{linkEnd} and make sure your changes will not unintentionally remove theirs.', -); +export const i18n = Object.freeze({ + alertMessage: __( + "Someone edited the %{issuableType} at the same time you did. Review %{linkStart}the %{issuableType}%{linkEnd} and make sure you don't unintentionally overwrite their changes.", + ), +}); export default { - alertMessage, components: { GlSprintf, GlLink, GlAlert, }, + props: { + issuableType: { + type: String, + required: true, + validator(value) { + return Object.values(IssuableType).includes(value); + }, + }, + }, computed: { currentPath() { return window.location.pathname; }, + alertMessage() { + return sprintf(this.$options.i18n.alertMessage, { issuableType: this.issuableType }); + }, }, + i18n, }; </script> <template> <gl-alert variant="danger" class="gl-mb-5" :dismissible="false"> - <gl-sprintf :message="$options.alertMessage"> + <gl-sprintf :message="alertMessage"> <template #link="{ content }"> <gl-link :href="currentPath" target="_blank" rel="nofollow"> {{ content }} diff --git a/app/assets/javascripts/issues/show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue index 307d9f9f69a..6978f730e1d 100644 --- a/app/assets/javascripts/issues/show/components/title.vue +++ b/app/assets/javascripts/issues/show/components/title.vue @@ -1,5 +1,6 @@ <script> -import { GlButton, GlTooltipDirective, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __ } from '~/locale'; import eventHub from '../event_hub'; import animateMixin from '../mixins/animate'; diff --git a/app/assets/javascripts/jira_connect/branches/components/source_branch_dropdown.vue b/app/assets/javascripts/jira_connect/branches/components/source_branch_dropdown.vue index 0e2d8821f36..dac807dceb0 100644 --- a/app/assets/javascripts/jira_connect/branches/components/source_branch_dropdown.vue +++ b/app/assets/javascripts/jira_connect/branches/components/source_branch_dropdown.vue @@ -1,5 +1,6 @@ <script> -import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; +import { debounce } from 'lodash'; import { __ } from '~/locale'; import { BRANCHES_PER_PAGE } from '../constants'; import getProjectQuery from '../graphql/queries/get_project.query.graphql'; @@ -7,10 +8,7 @@ import getProjectQuery from '../graphql/queries/get_project.query.graphql'; export default { BRANCHES_PER_PAGE, components: { - GlDropdown, - GlDropdownItem, - GlSearchBoxByType, - GlLoadingIcon, + GlCollapsibleListbox, }, props: { selectedProject: { @@ -26,7 +24,6 @@ export default { }, data() { return { - sourceBranchSearchQuery: '', initialSourceBranchNamesLoading: false, sourceBranchNamesLoading: false, sourceBranchNames: [], @@ -59,6 +56,9 @@ export default { onSourceBranchSelect(branchName) { this.$emit('change', branchName); }, + onSearch: debounce(function debouncedSearch(branchSearchQuery) { + this.onSourceBranchSearchQuery(branchSearchQuery); + }, 250), onSourceBranchSearchQuery(branchSearchQuery) { this.branchSearchQuery = branchSearchQuery; this.fetchSourceBranchNames({ @@ -83,7 +83,10 @@ export default { }); const { branchNames, rootRef } = data?.project.repository || {}; - this.sourceBranchNames = branchNames || []; + this.sourceBranchNames = + branchNames.map((value) => { + return { text: value, value }; + }) || []; // Use root ref as the default selection if (rootRef && !this.hasSelectedSourceBranch) { @@ -102,33 +105,15 @@ export default { </script> <template> - <gl-dropdown - :text="branchDropdownText" - :loading="initialSourceBranchNamesLoading" - :disabled="!hasSelectedProject" + <gl-collapsible-listbox :class="{ 'gl-font-monospace': hasSelectedSourceBranch }" - > - <template #header> - <gl-search-box-by-type - :debounce="250" - :value="sourceBranchSearchQuery" - @input="onSourceBranchSearchQuery" - /> - </template> - - <gl-loading-icon v-show="sourceBranchNamesLoading" /> - <template v-if="!sourceBranchNamesLoading"> - <gl-dropdown-item - v-for="branchName in sourceBranchNames" - v-show="!sourceBranchNamesLoading" - :key="branchName" - :is-checked="branchName === selectedBranchName" - is-check-item - class="gl-font-monospace" - @click="onSourceBranchSelect(branchName)" - > - {{ branchName }} - </gl-dropdown-item> - </template> - </gl-dropdown> + :disabled="!hasSelectedProject" + :items="sourceBranchNames" + :loading="initialSourceBranchNamesLoading" + :searchable="true" + :searching="sourceBranchNamesLoading" + :toggle-text="branchDropdownText" + @search="onSearch" + @select="onSourceBranchSelect" + /> </template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js index fc365746b54..01bc5dfc66b 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/constants.js +++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js @@ -38,7 +38,7 @@ export const INTEGRATIONS_DOC_LINK = helpPagePath('integration/jira/development_ anchor: 'use-the-integration', }); export const OAUTH_SELF_MANAGED_DOC_LINK = helpPagePath('integration/jira/connect-app', { - anchor: 'install-the-gitlabcom-for-jira-cloud-app-for-self-managed-instances', + anchor: 'connect-the-gitlabcom-for-jira-cloud-app-for-self-managed-instances', }); export const GITLAB_COM_BASE_PATH = 'https://gitlab.com'; diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue index 5ff75e19425..7c6ff002014 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue @@ -5,10 +5,14 @@ import { s__ } from '~/locale'; import { reloadPage, persistBaseUrl, retrieveBaseUrl } from '~/jira_connect/subscriptions/utils'; import { updateInstallation, setApiBaseURL } from '~/jira_connect/subscriptions/api'; -import { I18N_UPDATE_INSTALLATION_ERROR_MESSAGE } from '~/jira_connect/subscriptions/constants'; +import { + GITLAB_COM_BASE_PATH, + I18N_UPDATE_INSTALLATION_ERROR_MESSAGE, +} from '~/jira_connect/subscriptions/constants'; import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types'; import SignInOauthButton from '../../../components/sign_in_oauth_button.vue'; +import SetupInstructions from './setup_instructions.vue'; import VersionSelectForm from './version_select_form.vue'; export default { @@ -16,12 +20,14 @@ export default { components: { GlButton, SignInOauthButton, + SetupInstructions, VersionSelectForm, }, data() { return { gitlabBasePath: null, loadingVersionSelect: false, + showSetupInstructions: false, }; }, computed: { @@ -37,6 +43,9 @@ export default { mounted() { this.gitlabBasePath = retrieveBaseUrl(); setApiBaseURL(this.gitlabBasePath); + if (this.gitlabBasePath !== GITLAB_COM_BASE_PATH) { + this.showSetupInstructions = true; + } }, methods: { ...mapMutations({ @@ -61,6 +70,9 @@ export default { this.loadingVersionSelect = false; }); }, + onSetupNext() { + this.showSetupInstructions = false; + }, onSignInError() { this.$emit('error'); }, @@ -88,19 +100,23 @@ export default { @submit="onVersionSelect" /> - <div v-else class="gl-text-center"> - <sign-in-oauth-button - class="gl-mb-5" - :gitlab-base-path="gitlabBasePath" - @sign-in="$emit('sign-in-oauth', $event)" - @error="onSignInError" - /> + <template v-else> + <setup-instructions v-if="showSetupInstructions" @next="onSetupNext" /> + + <div v-else class="gl-text-center"> + <sign-in-oauth-button + class="gl-mb-5" + :gitlab-base-path="gitlabBasePath" + @sign-in="$emit('sign-in-oauth', $event)" + @error="onSignInError" + /> - <div> - <gl-button category="tertiary" variant="confirm" @click="resetGitlabBasePath"> - {{ $options.i18n.changeVersionButtonText }} - </gl-button> + <div> + <gl-button category="tertiary" variant="confirm" @click="resetGitlabBasePath"> + {{ $options.i18n.changeVersionButtonText }} + </gl-button> + </div> </div> - </div> + </template> </div> </template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue new file mode 100644 index 00000000000..00fa739b518 --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue @@ -0,0 +1,35 @@ +<script> +import { GlButton, GlLink } from '@gitlab/ui'; +import { OAUTH_SELF_MANAGED_DOC_LINK } from '~/jira_connect/subscriptions/constants'; + +export default { + components: { + GlButton, + GlLink, + }, + OAUTH_SELF_MANAGED_DOC_LINK, +}; +</script> + +<template> + <div class="gl-max-w-62 gl-mx-auto gl-mt-7"> + <h3>{{ s__('JiraService|Continue setup in GitLab') }}</h3> + <p> + {{ + s__( + 'JiraService|In order to complete the set up, you’ll need to complete a few steps in GitLab.', + ) + }} + <gl-link + class="gl-reset-font-size!" + :href="$options.OAUTH_SELF_MANAGED_DOC_LINK" + target="_blank" + >{{ __('Learn more') }}</gl-link + > + </p> + + <gl-button variant="confirm" @click="$emit('next')"> + {{ __('Next') }} + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue index 6b32225ed11..37a65946b3f 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue @@ -55,7 +55,6 @@ export default { }, radioOptions: RADIO_OPTIONS, i18n: { - title: s__('JiraService|Welcome to GitLab for Jira'), saasRadioLabel: __('GitLab.com (SaaS)'), saasRadioHelp: __('Most common'), selfManagedRadioLabel: __('GitLab (self-managed)'), diff --git a/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue b/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue index e498a735898..67cdca6aa0a 100644 --- a/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue +++ b/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue @@ -1,13 +1,13 @@ <script> import { GlFilteredSearch } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { + OPERATORS_IS, + TOKEN_TITLE_STATUS, + TOKEN_TYPE_STATUS, +} from '~/vue_shared/components/filtered_search_bar/constants'; import JobStatusToken from './tokens/job_status_token.vue'; export default { - tokenTypes: { - status: 'status', - }, components: { GlFilteredSearch, }, @@ -22,12 +22,12 @@ export default { tokens() { return [ { - type: this.$options.tokenTypes.status, + type: TOKEN_TYPE_STATUS, icon: 'status', - title: s__('Jobs|Status'), + title: TOKEN_TITLE_STATUS, unique: true, token: JobStatusToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }, ]; }, @@ -35,7 +35,7 @@ export default { if (this.queryString?.statuses) { return [ { - type: 'status', + type: TOKEN_TYPE_STATUS, value: { data: this.queryString?.statuses, operator: '=', diff --git a/app/assets/javascripts/jobs/components/job/empty_state.vue b/app/assets/javascripts/jobs/components/job/empty_state.vue index 65b9600e664..d0a39025807 100644 --- a/app/assets/javascripts/jobs/components/job/empty_state.vue +++ b/app/assets/javascripts/jobs/components/job/empty_state.vue @@ -1,16 +1,12 @@ <script> import { GlLink } from '@gitlab/ui'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import LegacyManualVariablesForm from '~/jobs/components/job/legacy_manual_variables_form.vue'; import ManualVariablesForm from '~/jobs/components/job/manual_variables_form.vue'; export default { components: { GlLink, - LegacyManualVariablesForm, ManualVariablesForm, }, - mixins: [glFeatureFlagsMixin()], props: { illustrationPath: { type: String, @@ -20,6 +16,14 @@ export default { type: String, required: true, }, + isRetryable: { + type: Boolean, + required: true, + }, + jobId: { + type: Number, + required: true, + }, title: { type: String, required: true, @@ -54,9 +58,6 @@ export default { }, }, computed: { - isGraphQL() { - return this.glFeatures?.graphqlJobApp; - }, shouldRenderManualVariables() { return this.playable && !this.scheduled; }, @@ -77,14 +78,14 @@ export default { <p v-if="content" data-testid="job-empty-state-content">{{ content }}</p> </div> - <template v-if="isGraphQL"> - <manual-variables-form v-if="shouldRenderManualVariables" :action="action" /> - </template> - <template v-else> - <legacy-manual-variables-form v-if="shouldRenderManualVariables" :action="action" /> - </template> - <div class="text-content"> - <div v-if="action && !shouldRenderManualVariables" class="text-center"> + <manual-variables-form + v-if="shouldRenderManualVariables" + :is-retryable="isRetryable" + :job-id="jobId" + @hideManualVariablesForm="$emit('hideManualVariablesForm')" + /> + <div v-if="action && !shouldRenderManualVariables" class="text-content"> + <div class="text-center"> <gl-link :href="action.path" :data-method="action.method" diff --git a/app/assets/javascripts/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql b/app/assets/javascripts/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql new file mode 100644 index 00000000000..2b79892a072 --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql @@ -0,0 +1,16 @@ +mutation retryJobWithVariables($id: CiBuildID!, $variables: [CiVariableInput!]) { + jobRetry(input: { id: $id, variables: $variables }) { + job { + id + manualVariables { + nodes { + id + key + value + } + } + webPath + } + errors + } +} diff --git a/app/assets/javascripts/jobs/components/job/graphql/queries/get_job.query.graphql b/app/assets/javascripts/jobs/components/job/graphql/queries/get_job.query.graphql new file mode 100644 index 00000000000..aaf1dec8e0f --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/graphql/queries/get_job.query.graphql @@ -0,0 +1,17 @@ +query getJob($fullPath: ID!, $id: JobID!) { + project(fullPath: $fullPath) { + id + job(id: $id) { + id + manualJob + manualVariables { + nodes { + id + key + value + } + } + name + } + } +} diff --git a/app/assets/javascripts/jobs/components/job/job_app.vue b/app/assets/javascripts/jobs/components/job/job_app.vue index 81b65d175a7..c6d900ef13e 100644 --- a/app/assets/javascripts/jobs/components/job/job_app.vue +++ b/app/assets/javascripts/jobs/components/job/job_app.vue @@ -1,8 +1,9 @@ <script> -import { GlLoadingIcon, GlIcon, GlSafeHtmlDirective as SafeHtml, GlAlert } from '@gitlab/ui'; +import { GlLoadingIcon, GlIcon, GlAlert } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { throttle, isEmpty } from 'lodash'; import { mapGetters, mapState, mapActions } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; import { __, sprintf } from '~/locale'; import CiHeader from '~/vue_shared/components/header_ci_component.vue'; @@ -71,6 +72,7 @@ export default { data() { return { searchResults: [], + showUpdateVariablesState: false, }; }, computed: { @@ -121,6 +123,10 @@ export default { return this.shouldRenderCalloutMessage && !this.hasUnmetPrerequisitesFailure; }, + isJobRetryable() { + return Boolean(this.job.retry_path); + }, + itemName() { return sprintf(__('Job %{jobName}'), { jobName: this.job.name }); }, @@ -168,10 +174,16 @@ export default { 'toggleScrollButtons', 'toggleScrollAnimation', ]), + onHideManualVariablesForm() { + this.showUpdateVariablesState = false; + }, onResize() { this.updateSidebar(); this.updateScroll(); }, + onUpdateVariables() { + this.showUpdateVariablesState = true; + }, updateSidebar() { const breakpoint = bp.getBreakpointSize(); if (breakpoint === 'xs' || breakpoint === 'sm') { @@ -271,14 +283,12 @@ export default { </div> <!-- job log --> <div - v-if="hasJobLog" + v-if="hasJobLog && !showUpdateVariablesState" class="build-log-container gl-relative" :class="{ 'gl-mt-3': !job.archived }" > <log-top-bar :class="{ - 'sidebar-expanded': isSidebarOpen, - 'sidebar-collapsed': !isSidebarOpen, 'has-archived-block': job.archived, }" :size="jobLogSize" @@ -299,14 +309,17 @@ export default { <!-- empty state --> <empty-state - v-if="!hasJobLog" + v-if="!hasJobLog || showUpdateVariablesState" :illustration-path="emptyStateIllustration.image" :illustration-size-class="emptyStateIllustration.size" + :is-retryable="isJobRetryable" + :job-id="job.id" :title="emptyStateTitle" :content="emptyStateIllustration.content" :action="emptyStateAction" :playable="job.playable" :scheduled="job.scheduled" + @hideManualVariablesForm="onHideManualVariablesForm()" /> <!-- EO empty state --> @@ -320,9 +333,9 @@ export default { 'right-sidebar-expanded': isSidebarOpen, 'right-sidebar-collapsed': !isSidebarOpen, }" - :erase-path="job.erase_path" :artifact-help-url="artifactHelpUrl" data-testid="job-sidebar" + @updateVariables="onUpdateVariables()" /> </div> </template> diff --git a/app/assets/javascripts/jobs/components/job/legacy_manual_variables_form.vue b/app/assets/javascripts/jobs/components/job/legacy_manual_variables_form.vue deleted file mode 100644 index 1898e02c94e..00000000000 --- a/app/assets/javascripts/jobs/components/job/legacy_manual_variables_form.vue +++ /dev/null @@ -1,192 +0,0 @@ -<script> -import { - GlFormInputGroup, - GlInputGroupText, - GlFormInput, - GlButton, - GlLink, - GlSprintf, -} from '@gitlab/ui'; -import { uniqueId } from 'lodash'; -import { mapActions } from 'vuex'; -import { helpPagePath } from '~/helpers/help_page_helper'; -import { s__ } from '~/locale'; - -export default { - name: 'ManualVariablesForm', - components: { - GlFormInputGroup, - GlInputGroupText, - GlFormInput, - GlButton, - GlLink, - GlSprintf, - }, - props: { - action: { - type: Object, - required: false, - default: null, - validator(value) { - return ( - value === null || - (Object.prototype.hasOwnProperty.call(value, 'path') && - Object.prototype.hasOwnProperty.call(value, 'method') && - Object.prototype.hasOwnProperty.call(value, 'button_title')) - ); - }, - }, - }, - inputTypes: { - key: 'key', - value: 'value', - }, - i18n: { - header: s__('CiVariables|Variables'), - keyLabel: s__('CiVariables|Key'), - valueLabel: s__('CiVariables|Value'), - keyPlaceholder: s__('CiVariables|Input variable key'), - valuePlaceholder: s__('CiVariables|Input variable value'), - formHelpText: s__( - 'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default', - ), - }, - data() { - return { - variables: [ - { - key: '', - secretValue: '', - id: uniqueId(), - }, - ], - triggerBtnDisabled: false, - }; - }, - computed: { - variableSettings() { - return helpPagePath('ci/variables/index', { anchor: 'add-a-cicd-variable-to-a-project' }); - }, - preparedVariables() { - // we need to ensure no empty variables are passed to the API - // and secretValue should be snake_case when passed to the API - return this.variables - .filter((variable) => variable.key !== '') - .map(({ key, secretValue }) => ({ key, secret_value: secretValue })); - }, - }, - methods: { - ...mapActions(['triggerManualJob']), - addEmptyVariable() { - const lastVar = this.variables[this.variables.length - 1]; - - if (lastVar.key === '') { - return; - } - - this.variables.push({ - key: '', - secret_value: '', - id: uniqueId(), - }); - }, - canRemove(index) { - return index < this.variables.length - 1; - }, - deleteVariable(id) { - this.variables.splice( - this.variables.findIndex((el) => el.id === id), - 1, - ); - }, - inputRef(type, id) { - return `${this.$options.inputTypes[type]}-${id}`; - }, - trigger() { - this.triggerBtnDisabled = true; - - this.triggerManualJob(this.preparedVariables); - }, - }, -}; -</script> -<template> - <div class="row gl-justify-content-center"> - <div class="col-10" data-testid="manual-vars-form"> - <label>{{ $options.i18n.header }}</label> - - <div - v-for="(variable, index) in variables" - :key="variable.id" - class="gl-display-flex gl-align-items-center gl-mb-4" - data-testid="ci-variable-row" - > - <gl-form-input-group class="gl-mr-4 gl-flex-grow-1"> - <template #prepend> - <gl-input-group-text> - {{ $options.i18n.keyLabel }} - </gl-input-group-text> - </template> - <gl-form-input - :ref="inputRef('key', variable.id)" - v-model="variable.key" - :placeholder="$options.i18n.keyPlaceholder" - data-testid="ci-variable-key" - @change="addEmptyVariable" - /> - </gl-form-input-group> - - <gl-form-input-group class="gl-flex-grow-2"> - <template #prepend> - <gl-input-group-text> - {{ $options.i18n.valueLabel }} - </gl-input-group-text> - </template> - <gl-form-input - :ref="inputRef('value', variable.id)" - v-model="variable.secretValue" - :placeholder="$options.i18n.valuePlaceholder" - data-testid="ci-variable-value" - /> - </gl-form-input-group> - - <gl-button - v-if="canRemove(index)" - class="gl-flex-grow-0 gl-flex-basis-0" - category="tertiary" - variant="danger" - icon="clear" - :aria-label="__('Delete variable')" - data-testid="delete-variable-btn" - @click="deleteVariable(variable.id)" - /> - - <!-- delete variable button placeholder to not break flex layout --> - <div v-else class="gl-w-7 gl-mr-3" data-testid="delete-variable-btn-placeholder"></div> - </div> - - <div class="gl-text-center gl-mt-5"> - <gl-sprintf :message="$options.i18n.formHelpText"> - <template #link="{ content }"> - <gl-link :href="variableSettings" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </div> - <div class="gl-display-flex gl-justify-content-center gl-mt-5"> - <gl-button - class="gl-mt-5" - variant="confirm" - category="primary" - :aria-label="__('Trigger manual job')" - :disabled="triggerBtnDisabled" - data-testid="trigger-manual-job-btn" - @click="trigger" - > - {{ action.button_title }} - </gl-button> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue index 2f97301979c..d7bbd6daed2 100644 --- a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue +++ b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue @@ -5,15 +5,24 @@ import { GlFormInput, GlButton, GlLink, + GlLoadingIcon, GlSprintf, + GlTooltipDirective, } from '@gitlab/ui'; -import { uniqueId } from 'lodash'; +import { cloneDeep, uniqueId } from 'lodash'; import { mapActions } from 'vuex'; +import { fetchPolicies } from '~/lib/graphql'; +import { createAlert } from '~/flash'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { JOB_GRAPHQL_ERRORS, GRAPHQL_ID_TYPES } from '~/jobs/constants'; import { helpPagePath } from '~/helpers/help_page_helper'; +import { redirectTo } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; +import GetJob from './graphql/queries/get_job.query.graphql'; +import retryJobWithVariablesMutation from './graphql/mutations/job_retry_with_variables.mutation.graphql'; // This component is a port of ~/jobs/components/job/legacy_manual_variables_form.vue -// It is meant to fetch the job information via GraphQL instead of REST API. +// It is meant to fetch/update the job information via GraphQL instead of REST API. export default { name: 'ManualVariablesForm', @@ -23,59 +32,93 @@ export default { GlFormInput, GlButton, GlLink, + GlLoadingIcon, GlSprintf, }, - props: { - action: { - type: Object, - required: false, - default: null, - validator(value) { - return ( - value === null || - (Object.prototype.hasOwnProperty.call(value, 'path') && - Object.prototype.hasOwnProperty.call(value, 'method') && - Object.prototype.hasOwnProperty.call(value, 'button_title')) - ); + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['projectPath'], + apollo: { + variables: { + query: GetJob, + variables() { + return { + fullPath: this.projectPath, + id: convertToGraphQLId(GRAPHQL_ID_TYPES.commitStatus, this.jobId), + }; + }, + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + update(data) { + const jobVariables = cloneDeep(data?.project?.job?.manualVariables?.nodes); + return [...jobVariables.reverse(), ...this.variables]; + }, + error() { + createAlert({ message: JOB_GRAPHQL_ERRORS.jobQueryErrorText }); }, }, }, + props: { + isRetryable: { + type: Boolean, + required: true, + }, + jobId: { + type: Number, + required: true, + }, + }, inputTypes: { key: 'key', value: 'value', }, i18n: { + clearInputs: s__('CiVariables|Clear inputs'), + formHelpText: s__( + 'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default', + ), header: s__('CiVariables|Variables'), keyLabel: s__('CiVariables|Key'), - valueLabel: s__('CiVariables|Value'), keyPlaceholder: s__('CiVariables|Input variable key'), + runAgainButtonText: s__('CiVariables|Run job again'), + triggerButtonText: s__('CiVariables|Trigger this manual action'), + valueLabel: s__('CiVariables|Value'), valuePlaceholder: s__('CiVariables|Input variable value'), - formHelpText: s__( - 'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default', - ), + }, + variableValueKeys: { + rest: 'secret_value', + gql: 'value', }, data() { return { + job: {}, variables: [ { - key: '', - secretValue: '', id: uniqueId(), + key: '', + value: '', }, ], + runAgainBtnDisabled: false, triggerBtnDisabled: false, }; }, computed: { - variableSettings() { - return helpPagePath('ci/variables/index', { anchor: 'add-a-cicd-variable-to-a-project' }); - }, preparedVariables() { - // we need to ensure no empty variables are passed to the API - // and secretValue should be snake_case when passed to the API + // filtering out 'id' along with empty variables to send only key, value in the mutation. + // This will be removed in: https://gitlab.com/gitlab-org/gitlab/-/issues/377268 + return this.variables .filter((variable) => variable.key !== '') - .map(({ key, secretValue }) => ({ key, secret_value: secretValue })); + .map(({ key, value }) => ({ key, [this.valueKey]: value })); + }, + valueKey() { + return this.isRetryable + ? this.$options.variableValueKeys.gql + : this.$options.variableValueKeys.rest; + }, + variableSettings() { + return helpPagePath('ci/variables/index', { anchor: 'add-a-cicd-variable-to-a-project' }); }, }, methods: { @@ -88,9 +131,9 @@ export default { } this.variables.push({ - key: '', - secret_value: '', id: uniqueId(), + key: '', + value: '', }); }, canRemove(index) { @@ -105,7 +148,34 @@ export default { inputRef(type, id) { return `${this.$options.inputTypes[type]}-${id}`; }, - trigger() { + navigateToRetriedJob(retryPath) { + redirectTo(retryPath); + }, + async retryJob() { + try { + const { data } = await this.$apollo.mutate({ + mutation: retryJobWithVariablesMutation, + variables: { + id: convertToGraphQLId(GRAPHQL_ID_TYPES.ciBuild, this.jobId), + // we need to ensure no empty variables are passed to the API + variables: this.preparedVariables, + }, + }); + if (data.jobRetry?.errors?.length) { + createAlert({ message: data.jobRetry.errors[0] }); + } else { + this.navigateToRetriedJob(data.jobRetry?.job?.webPath); + } + } catch (error) { + createAlert({ message: JOB_GRAPHQL_ERRORS.retryMutationErrorText }); + } + }, + runAgain() { + this.runAgainBtnDisabled = true; + + this.retryJob(); + }, + triggerJob() { this.triggerBtnDisabled = true; this.triggerManualJob(this.preparedVariables); @@ -114,7 +184,8 @@ export default { }; </script> <template> - <div class="row gl-justify-content-center"> + <gl-loading-icon v-if="$apollo.queries.variables.loading" class="gl-mt-9" size="lg" /> + <div v-else class="row gl-justify-content-center"> <div class="col-10" data-testid="manual-vars-form"> <label>{{ $options.i18n.header }}</label> @@ -147,7 +218,7 @@ export default { </template> <gl-form-input :ref="inputRef('value', variable.id)" - v-model="variable.secretValue" + v-model="variable.value" :placeholder="$options.i18n.valuePlaceholder" data-testid="ci-variable-value" /> @@ -155,11 +226,13 @@ export default { <gl-button v-if="canRemove(index)" + v-gl-tooltip + :aria-label="$options.i18n.clearInputs" + :title="$options.i18n.clearInputs" class="gl-flex-grow-0 gl-flex-basis-0" category="tertiary" variant="danger" icon="clear" - :aria-label="__('Delete variable')" data-testid="delete-variable-btn" @click="deleteVariable(variable.id)" /> @@ -177,7 +250,27 @@ export default { </template> </gl-sprintf> </div> - <div class="gl-display-flex gl-justify-content-center gl-mt-5"> + <div v-if="isRetryable" class="gl-display-flex gl-justify-content-center gl-mt-5"> + <gl-button + class="gl-mt-5" + :aria-label="__('Cancel')" + data-testid="cancel-btn" + @click="$emit('hideManualVariablesForm')" + >{{ __('Cancel') }}</gl-button + > + <gl-button + class="gl-mt-5" + variant="confirm" + category="primary" + :aria-label="__('Run manual job again')" + :disabled="runAgainBtnDisabled" + data-testid="run-manual-job-btn" + @click="runAgain" + > + {{ $options.i18n.runAgainButtonText }} + </gl-button> + </div> + <div v-else class="gl-display-flex gl-justify-content-center gl-mt-5"> <gl-button class="gl-mt-5" variant="confirm" @@ -185,9 +278,9 @@ export default { :aria-label="__('Trigger manual job')" :disabled="triggerBtnDisabled" data-testid="trigger-manual-job-btn" - @click="trigger" + @click="triggerJob" > - {{ action.button_title }} + {{ $options.i18n.triggerButtonText }} </gl-button> </div> </div> diff --git a/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue index dd620977f0c..7183a8b5d03 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue @@ -1,15 +1,17 @@ <script> -import { GlButton, GlModalDirective } from '@gitlab/ui'; +import { GlButton, GlDropdown, GlDropdownItem, GlModalDirective } from '@gitlab/ui'; import { mapGetters } from 'vuex'; import { JOB_SIDEBAR_COPY } from '~/jobs/constants'; export default { name: 'JobSidebarRetryButton', i18n: { - retryLabel: JOB_SIDEBAR_COPY.retry, + ...JOB_SIDEBAR_COPY, }, components: { GlButton, + GlDropdown, + GlDropdownItem, }, directives: { GlModal: GlModalDirective, @@ -23,6 +25,10 @@ export default { type: String, required: true, }, + isManualJob: { + type: Boolean, + required: true, + }, }, computed: { ...mapGetters(['hasForwardDeploymentFailure']), @@ -33,17 +39,30 @@ export default { <gl-button v-if="hasForwardDeploymentFailure" v-gl-modal="modalId" - :aria-label="$options.i18n.retryLabel" + :aria-label="$options.i18n.retryJobLabel" category="primary" variant="confirm" icon="retry" data-testid="retry-job-button" /> - + <gl-dropdown + v-else-if="isManualJob" + icon="retry" + category="primary" + :right="true" + variant="confirm" + > + <gl-dropdown-item :href="href" data-method="post"> + {{ $options.i18n.runAgainJobButtonLabel }} + </gl-dropdown-item> + <gl-dropdown-item @click="$emit('updateVariablesClicked')"> + {{ $options.i18n.updateVariables }} + </gl-dropdown-item> + </gl-dropdown> <gl-button v-else :href="href" - :aria-label="$options.i18n.retryLabel" + :aria-label="$options.i18n.retryJobLabel" category="primary" variant="confirm" icon="retry" diff --git a/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue deleted file mode 100644 index 64b497c3550..00000000000 --- a/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue +++ /dev/null @@ -1,104 +0,0 @@ -<script> -import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { mapActions } from 'vuex'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; -import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants'; -import JobSidebarRetryButton from './job_sidebar_retry_button.vue'; - -export default { - name: 'LegacySidebarHeader', - i18n: { - ...JOB_SIDEBAR_COPY, - }, - forwardDeploymentFailureModalId, - directives: { - GlTooltip: GlTooltipDirective, - }, - components: { - GlButton, - JobSidebarRetryButton, - TooltipOnTruncate, - }, - props: { - job: { - type: Object, - required: true, - default: () => ({}), - }, - erasePath: { - type: String, - required: false, - default: null, - }, - }, - computed: { - retryButtonCategory() { - return this.job.status && this.job.recoverable ? 'primary' : 'secondary'; - }, - buttonTitle() { - return this.job.status && this.job.status.text === 'passed' - ? this.$options.i18n.runAgainJobButtonLabel - : this.$options.i18n.retryJobButtonLabel; - }, - }, - methods: { - ...mapActions(['toggleSidebar']), - }, -}; -</script> - -<template> - <div class="gl-py-5 gl-display-flex gl-align-items-center"> - <tooltip-on-truncate :title="job.name" truncate-target="child" - ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate"> - {{ job.name }} - </h4> - </tooltip-on-truncate> - <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right"> - <gl-button - v-if="erasePath" - v-gl-tooltip.left - :title="$options.i18n.eraseLogButtonLabel" - :aria-label="$options.i18n.eraseLogButtonLabel" - :href="erasePath" - :data-confirm="$options.i18n.eraseLogConfirmText" - class="gl-mr-2" - data-testid="job-log-erase-link" - data-confirm-btn-variant="danger" - data-method="post" - icon="remove" - /> - <job-sidebar-retry-button - v-if="job.retry_path" - v-gl-tooltip.left - :title="buttonTitle" - :aria-label="buttonTitle" - :category="retryButtonCategory" - :href="job.retry_path" - :modal-id="$options.forwardDeploymentFailureModalId" - variant="confirm" - data-qa-selector="retry_button" - data-testid="retry-button" - /> - <gl-button - v-if="job.cancel_path" - v-gl-tooltip.left - :title="$options.i18n.cancelJobButtonLabel" - :aria-label="$options.i18n.cancelJobButtonLabel" - :href="job.cancel_path" - variant="danger" - icon="cancel" - data-method="post" - data-testid="cancel-button" - rel="nofollow" - /> - <gl-button - :aria-label="$options.i18n.toggleSidebar" - category="tertiary" - class="gl-md-display-none gl-ml-2" - icon="chevron-double-lg-right" - @click="toggleSidebar" - /> - </div> - </div> -</template> diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue index aac6a0ad6d3..69271cc9022 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue @@ -2,14 +2,12 @@ import { GlButton, GlIcon } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants'; +import ArtifactsBlock from './artifacts_block.vue'; import CommitBlock from './commit_block.vue'; import JobsContainer from './jobs_container.vue'; import JobRetryForwardDeploymentModal from './job_retry_forward_deployment_modal.vue'; import JobSidebarDetailsContainer from './sidebar_job_details_container.vue'; -import ArtifactsBlock from './artifacts_block.vue'; -import LegacySidebarHeader from './legacy_sidebar_header.vue'; import SidebarHeader from './sidebar_header.vue'; import StagesDropdown from './stages_dropdown.vue'; import TriggerBlock from './trigger_block.vue'; @@ -29,23 +27,16 @@ export default { JobsContainer, JobRetryForwardDeploymentModal, JobSidebarDetailsContainer, - LegacySidebarHeader, SidebarHeader, StagesDropdown, TriggerBlock, }, - mixins: [glFeatureFlagsMixin()], props: { artifactHelpUrl: { type: String, required: false, default: '', }, - erasePath: { - type: String, - required: false, - default: null, - }, }, computed: { ...mapGetters(['hasForwardDeploymentFailure']), @@ -57,9 +48,6 @@ export default { hasTriggers() { return !isEmpty(this.job.trigger); }, - isGraphQL() { - return this.glFeatures?.graphqlJobApp; - }, commit() { return this.job?.pipeline?.commit || {}; }, @@ -89,8 +77,11 @@ export default { <aside class="right-sidebar build-sidebar" data-offset-top="101" data-spy="affix"> <div class="sidebar-container"> <div class="blocks-container"> - <sidebar-header v-if="isGraphQL" :erase-path="erasePath" :job="job" /> - <legacy-sidebar-header v-else :erase-path="erasePath" :job="job" /> + <sidebar-header + :rest-job="job" + :job-id="job.id" + @updateVariables="$emit('updateVariables')" + /> <div v-if="job.terminal_path || job.new_issue_path" class="gl-py-5" diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue index 523710598bf..40aec0b0536 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue @@ -1,13 +1,19 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { mapActions } from 'vuex'; +import { createAlert } from '~/flash'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; -import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants'; +import { + JOB_GRAPHQL_ERRORS, + GRAPHQL_ID_TYPES, + JOB_SIDEBAR_COPY, + forwardDeploymentFailureModalId, + PASSED_STATUS, +} from '~/jobs/constants'; +import GetJob from '../graphql/queries/get_job.query.graphql'; import JobSidebarRetryButton from './job_sidebar_retry_button.vue'; -// This component is a port of ~/jobs/components/job/sidebar/legacy_sidebar_header.vue -// It is meant to fetch the job information via GraphQL instead of REST API. - export default { name: 'SidebarHeader', i18n: { @@ -22,21 +28,58 @@ export default { JobSidebarRetryButton, TooltipOnTruncate, }, - props: { + inject: ['projectPath'], + apollo: { job: { + query: GetJob, + variables() { + return { + fullPath: this.projectPath, + id: convertToGraphQLId(GRAPHQL_ID_TYPES.commitStatus, this.jobId), + }; + }, + update(data) { + const { name, manualJob } = data?.project?.job || {}; + return { + name, + manualJob, + }; + }, + error() { + createAlert({ message: JOB_GRAPHQL_ERRORS.jobQueryErrorText }); + }, + }, + }, + props: { + jobId: { + type: Number, + required: true, + }, + restJob: { type: Object, required: true, default: () => ({}), }, - erasePath: { - type: String, - required: false, - default: null, - }, + }, + data() { + return { + job: {}, + }; }, computed: { + buttonTitle() { + return this.restJob.status?.text === PASSED_STATUS + ? this.$options.i18n.runAgainJobButtonLabel + : this.$options.i18n.retryJobLabel; + }, + canShowJobRetryButton() { + return this.restJob.retry_path && !this.$apollo.queries.job.loading; + }, + isManualJob() { + return this.job?.manualJob; + }, retryButtonCategory() { - return this.job.status && this.job.recoverable ? 'primary' : 'secondary'; + return this.restJob.status && this.restJob.recoverable ? 'primary' : 'secondary'; }, }, methods: { @@ -48,17 +91,15 @@ export default { <template> <div class="gl-py-5 gl-display-flex gl-align-items-center"> <tooltip-on-truncate :title="job.name" truncate-target="child" - ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate"> - {{ job.name }} - </h4> + ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate" data-testid="job-name">{{ job.name }}</h4> </tooltip-on-truncate> <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right"> <gl-button - v-if="erasePath" + v-if="restJob.erase_path" v-gl-tooltip.left :title="$options.i18n.eraseLogButtonLabel" :aria-label="$options.i18n.eraseLogButtonLabel" - :href="erasePath" + :href="restJob.erase_path" :data-confirm="$options.i18n.eraseLogConfirmText" class="gl-mr-2" data-testid="job-log-erase-link" @@ -67,23 +108,25 @@ export default { icon="remove" /> <job-sidebar-retry-button - v-if="job.retry_path" + v-if="canShowJobRetryButton" v-gl-tooltip.left - :title="$options.i18n.retryJobButtonLabel" - :aria-label="$options.i18n.retryJobButtonLabel" + :title="buttonTitle" + :aria-label="buttonTitle" + :is-manual-job="isManualJob" :category="retryButtonCategory" - :href="job.retry_path" + :href="restJob.retry_path" :modal-id="$options.forwardDeploymentFailureModalId" variant="confirm" data-qa-selector="retry_button" data-testid="retry-button" + @updateVariablesClicked="$emit('updateVariables')" /> <gl-button - v-if="job.cancel_path" + v-if="restJob.cancel_path" v-gl-tooltip.left :title="$options.i18n.cancelJobButtonLabel" :aria-label="$options.i18n.cancelJobButtonLabel" - :href="job.cancel_path" + :href="restJob.cancel_path" variant="danger" icon="cancel" data-method="post" diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue index 3b1509e5be5..8300a22cb67 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue @@ -1,6 +1,7 @@ <script> import { mapState } from 'vuex'; import { GlBadge } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; import { __, sprintf } from '~/locale'; import timeagoMixin from '~/vue_shared/mixins/timeago'; @@ -79,7 +80,9 @@ export default { TAGS: __('Tags:'), TIMEOUT: __('Timeout'), }, - RUNNER_HELP_URL: 'https://docs.gitlab.com/runner/register/index.html', + TIMEOUT_HELP_URL: helpPagePath('/ci/pipelines/settings.md', { + anchor: 'set-a-limit-for-how-long-jobs-can-run', + }), }; </script> @@ -96,7 +99,7 @@ export default { <detail-row v-if="job.queued_duration" :value="queuedDuration" :title="$options.i18n.QUEUED" /> <detail-row v-if="hasTimeout" - :help-url="$options.RUNNER_HELP_URL" + :help-url="$options.TIMEOUT_HELP_URL" :value="timeout" data-testid="job-timeout" :title="$options.i18n.TIMEOUT" diff --git a/app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue index 1afc1c9a595..c9172fe0322 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue @@ -2,9 +2,7 @@ import { GlButton, GlTableLite } from '@gitlab/ui'; import { __ } from '~/locale'; -const DEFAULT_TD_CLASSES = 'gl-w-half gl-font-sm! gl-border-gray-200!'; -const DEFAULT_TH_CLASSES = - 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-200! gl-border-b-1!'; +const DEFAULT_TD_CLASSES = 'gl-font-sm!'; export default { fields: [ @@ -13,14 +11,12 @@ export default { label: __('Key'), tdAttr: { 'data-testid': 'trigger-build-key' }, tdClass: DEFAULT_TD_CLASSES, - thClass: DEFAULT_TH_CLASSES, }, { key: 'value', label: __('Value'), tdAttr: { 'data-testid': 'trigger-build-value' }, tdClass: DEFAULT_TD_CLASSES, - thClass: DEFAULT_TH_CLASSES, }, ], components: { diff --git a/app/assets/javascripts/jobs/constants.js b/app/assets/javascripts/jobs/constants.js index e9475994e8b..405aea11181 100644 --- a/app/assets/javascripts/jobs/constants.js +++ b/app/assets/javascripts/jobs/constants.js @@ -5,6 +5,11 @@ const moreInfo = __('More information'); export const forwardDeploymentFailureModalId = 'forward-deployment-failure'; +export const GRAPHQL_ID_TYPES = { + commitStatus: 'CommitStatus', + ciBuild: 'Ci::Build', +}; + export const JOB_SIDEBAR_COPY = { cancel, cancelJobButtonLabel: s__('Job|Cancel'), @@ -12,10 +17,15 @@ export const JOB_SIDEBAR_COPY = { eraseLogButtonLabel: s__('Job|Erase job log and artifacts'), eraseLogConfirmText: s__('Job|Are you sure you want to erase this job log and artifacts?'), newIssue: __('New issue'), - retry: __('Retry'), - retryJobButtonLabel: s__('Job|Retry'), + retryJobLabel: s__('Job|Retry'), toggleSidebar: __('Toggle Sidebar'), runAgainJobButtonLabel: s__('Job|Run again'), + updateVariables: s__('Job|Update CI/CD variables'), +}; + +export const JOB_GRAPHQL_ERRORS = { + retryMutationErrorText: __('There was an error running the job. Please try again.'), + jobQueryErrorText: __('There was an error fetching the job.'), }; export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = { @@ -31,3 +41,4 @@ export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = { }; export const SUCCESS_STATUS = 'SUCCESS'; +export const PASSED_STATUS = 'passed'; diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js index 9dd47f4046c..44bb1ffb1bc 100644 --- a/app/assets/javascripts/jobs/index.js +++ b/app/assets/javascripts/jobs/index.js @@ -1,10 +1,17 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import JobApp from './components/job/job_app.vue'; import createStore from './store'; +Vue.use(VueApollo); Vue.use(GlToast); +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + const initializeJobPage = (element) => { const store = createStore(); @@ -26,11 +33,13 @@ const initializeJobPage = (element) => { return new Vue({ el: element, + apolloProvider, store, components: { JobApp, }, provide: { + projectPath, retryOutdatedJobDocsUrl, }, render(createElement) { diff --git a/app/assets/javascripts/labels/labels_select.js b/app/assets/javascripts/labels/labels_select.js index 65dda804a20..515b0a79a03 100644 --- a/app/assets/javascripts/labels/labels_select.js +++ b/app/assets/javascripts/labels/labels_select.js @@ -4,7 +4,7 @@ import $ from 'jquery'; import { difference, isEqual, escape, sortBy, template, union } from 'lodash'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import IssuableBulkUpdateActions from '~/issuable/bulk_update_sidebar/issuable_bulk_update_actions'; +import IssuableBulkUpdateActions from '~/issuable/issuable_bulk_update_actions'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; diff --git a/app/assets/javascripts/language_switcher/components/app.vue b/app/assets/javascripts/language_switcher/components/app.vue new file mode 100644 index 00000000000..71babe6c614 --- /dev/null +++ b/app/assets/javascripts/language_switcher/components/app.vue @@ -0,0 +1,49 @@ +<script> +import { GlCollapsibleListbox } from '@gitlab/ui'; +import { setCookie } from '~/lib/utils/common_utils'; +import { PREFERRED_LANGUAGE_COOKIE_KEY } from '../constants'; + +export default { + components: { + GlCollapsibleListbox, + }, + inject: { + locales: { + default: [], + }, + preferredLocale: { + default: {}, + }, + }, + data() { + return { + selected: this.preferredLocale.value, + }; + }, + methods: { + onLanguageSelected(code) { + setCookie(PREFERRED_LANGUAGE_COOKIE_KEY, code); + window.location.reload(); + }, + }, +}; +</script> +<template> + <gl-collapsible-listbox + v-model="selected" + :toggle-text="preferredLocale.text" + :items="locales" + category="tertiary" + right + icon="earth" + size="small" + toggle-class="py-0 gl-h-6" + @select="onLanguageSelected" + > + <template #list-item="{ item: locale }"> + <span :data-testid="`language_switcher_lang_${locale.value}`"> + {{ locale.text }} + </span> + </template> + </gl-collapsible-listbox> +</template> diff --git a/app/assets/javascripts/language_switcher/constants.js b/app/assets/javascripts/language_switcher/constants.js new file mode 100644 index 00000000000..b5c0613ac01 --- /dev/null +++ b/app/assets/javascripts/language_switcher/constants.js @@ -0,0 +1 @@ +export const PREFERRED_LANGUAGE_COOKIE_KEY = 'preferred_language'; diff --git a/app/assets/javascripts/language_switcher/index.js b/app/assets/javascripts/language_switcher/index.js new file mode 100644 index 00000000000..b224e2510bb --- /dev/null +++ b/app/assets/javascripts/language_switcher/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import { getCookie } from '~/lib/utils/common_utils'; +import LanguageSwitcher from './components/app.vue'; +import { PREFERRED_LANGUAGE_COOKIE_KEY } from './constants'; + +export const initLanguageSwitcher = () => { + const el = document.querySelector('.js-language-switcher'); + if (!el) return false; + const locales = JSON.parse(el.dataset.locales); + const preferredLangCode = getCookie(PREFERRED_LANGUAGE_COOKIE_KEY); + const preferredLocale = locales.find((locale) => locale.value === preferredLangCode); + + return new Vue({ + el, + provide: { + locales, + preferredLocale, + }, + render(createElement) { + return createElement(LanguageSwitcher); + }, + }); +}; diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js index 27760e483aa..5372f6555d2 100644 --- a/app/assets/javascripts/lib/dompurify.js +++ b/app/assets/javascripts/lib/dompurify.js @@ -18,7 +18,7 @@ export const defaultConfig = { 'data-disable', 'data-turbo', ], - FORBID_TAGS: ['style', 'mstyle'], + FORBID_TAGS: ['style', 'mstyle', 'form'], ALLOW_UNKNOWN_PROTOCOLS: true, }; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index beced4f9144..4ce63d518a6 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -4,9 +4,9 @@ import { GlBreakpointInstance as breakpointInstance } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; -import { isFunction, defer } from 'lodash'; +import { isFunction, defer, escape } from 'lodash'; import Cookies from '~/lib/utils/cookies'; -import { SCOPED_LABEL_DELIMITER } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; +import { SCOPED_LABEL_DELIMITER } from '~/sidebar/components/labels/labels_select_widget/constants'; import { convertToCamelCase, convertToSnakeCase } from './text_utility'; import { isObject } from './type_utility'; import { getLocationHash } from './url_utility'; @@ -28,16 +28,12 @@ export const checkPageAndAction = (page, action) => { export const isInIncidentPage = () => checkPageAndAction('incidents', 'show'); export const isInIssuePage = () => checkPageAndAction('issues', 'show'); export const isInDesignPage = () => checkPageAndAction('issues', 'designs'); -export const isInMRPage = () => checkPageAndAction('merge_requests', 'show'); +export const isInMRPage = () => + checkPageAndAction('merge_requests', 'show') || checkPageAndAction('merge_requests', 'diffs'); export const isInEpicPage = () => checkPageAndAction('epics', 'show'); export const getDashPath = (path = window.location.pathname) => path.split('/-/')[1] || null; -export const getCspNonceValue = () => { - const metaTag = document.querySelector('meta[name=csp-nonce]'); - return metaTag && metaTag.content; -}; - export const rstrip = (val) => { if (val) { return val.replace(/\s+$/, ''); @@ -469,7 +465,7 @@ export const backOff = (fn, timeout = 60000) => { export const spriteIcon = (icon, className = '') => { const classAttribute = className.length > 0 ? `class="${className}"` : ''; - return `<svg ${classAttribute}><use xlink:href="${gon.sprite_icons}#${icon}" /></svg>`; + return `<svg ${classAttribute}><use xlink:href="${gon.sprite_icons}#${escape(icon)}" /></svg>`; }; /** @@ -715,3 +711,16 @@ export const getFirstPropertyValue = (data) => { return data[key]; }; + +// TODO: remove when FF `new_fonts` is removed https://gitlab.com/gitlab-org/gitlab/-/issues/379147 +/** + * This method checks the FF `new_fonts` + * as well as a query parameter `new_fonts`. + * If either of them is enabled, new fonts will be applied. + * + * @returns Boolean Whether to apply new fonts + */ +export const useNewFonts = () => { + const hasQueryParam = new URLSearchParams(window.location.search).has('new_fonts'); + return window?.gon.features?.newFonts || hasQueryParam; +}; diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue index 3788d8ab20c..ea91ccec546 100644 --- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue +++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue @@ -1,10 +1,11 @@ <script> -import { GlModal, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlModal } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __ } from '~/locale'; export default { directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, components: { GlModal, diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index 379c57f3945..2c8953237cf 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -1,6 +1,5 @@ export const BYTES_IN_KIB = 1024; export const DEFAULT_DEBOUNCE_AND_THROTTLE_MS = 250; -export const HIDDEN_CLASS = 'hidden'; export const THOUSAND = 1000; export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80; export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12; diff --git a/app/assets/javascripts/lib/utils/create_and_submit_form.js b/app/assets/javascripts/lib/utils/create_and_submit_form.js new file mode 100644 index 00000000000..fce4f898f2f --- /dev/null +++ b/app/assets/javascripts/lib/utils/create_and_submit_form.js @@ -0,0 +1,26 @@ +import csrf from '~/lib/utils/csrf'; + +export const createAndSubmitForm = ({ url, data }) => { + const form = document.createElement('form'); + + form.action = url; + // For now we only support 'post'. + // `form.method` doesn't support other methods so we would need to + // use a hidden `_method` input, which is out of scope for now. + form.method = 'post'; + form.style.display = 'none'; + + Object.entries(data) + .concat([['authenticity_token', csrf.token]]) + .forEach(([key, value]) => { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = key; + input.value = value; + + form.appendChild(input); + }); + + document.body.appendChild(form); + form.submit(); +}; diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js index cafee641174..317c401e404 100644 --- a/app/assets/javascripts/lib/utils/dom_utils.js +++ b/app/assets/javascripts/lib/utils/dom_utils.js @@ -118,3 +118,24 @@ export const getContentWrapperHeight = (contentWrapperClass) => { const wrapperEl = document.querySelector(contentWrapperClass); return wrapperEl ? `${wrapperEl.offsetTop}px` : ''; }; + +/** + * Replaces comment nodes in a DOM tree with a different element + * containing the text of the comment. + * + * @param {*} el + * @param {*} tagName + */ +export const replaceCommentsWith = (el, tagName) => { + const iterator = document.createNodeIterator(el, NodeFilter.SHOW_COMMENT); + let commentNode = iterator.nextNode(); + + while (commentNode) { + const newNode = document.createElement(tagName); + newNode.textContent = commentNode.textContent; + + commentNode.parentNode.replaceChild(newNode, commentNode); + + commentNode = iterator.nextNode(); + } +}; diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js index c5190592bb6..ec0d8d433a5 100644 --- a/app/assets/javascripts/lib/utils/http_status.js +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -1,45 +1,43 @@ -/** - * exports HTTP status codes - */ +export const HTTP_STATUS_ABORTED = 0; +export const HTTP_STATUS_CREATED = 201; +export const HTTP_STATUS_ACCEPTED = 202; +export const HTTP_STATUS_NON_AUTHORITATIVE_INFORMATION = 203; +export const HTTP_STATUS_NO_CONTENT = 204; +export const HTTP_STATUS_RESET_CONTENT = 205; +export const HTTP_STATUS_PARTIAL_CONTENT = 206; +export const HTTP_STATUS_MULTI_STATUS = 207; +export const HTTP_STATUS_ALREADY_REPORTED = 208; +export const HTTP_STATUS_IM_USED = 226; +export const HTTP_STATUS_METHOD_NOT_ALLOWED = 405; +export const HTTP_STATUS_CONFLICT = 409; +export const HTTP_STATUS_GONE = 410; +export const HTTP_STATUS_PAYLOAD_TOO_LARGE = 413; +export const HTTP_STATUS_UNPROCESSABLE_ENTITY = 422; +export const HTTP_STATUS_TOO_MANY_REQUESTS = 429; +// TODO move the rest of the status codes to primitive constants +// https://docs.gitlab.com/ee/development/fe_guide/style/javascript.html#export-constants-as-primitives const httpStatusCodes = { - ABORTED: 0, OK: 200, - CREATED: 201, - ACCEPTED: 202, - NON_AUTHORITATIVE_INFORMATION: 203, - NO_CONTENT: 204, - RESET_CONTENT: 205, - PARTIAL_CONTENT: 206, - MULTI_STATUS: 207, - ALREADY_REPORTED: 208, - IM_USED: 226, - MULTIPLE_CHOICES: 300, BAD_REQUEST: 400, UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, - METHOD_NOT_ALLOWED: 405, - CONFLICT: 409, - GONE: 410, - PAYLOAD_TOO_LARGE: 413, - UNPROCESSABLE_ENTITY: 422, - TOO_MANY_REQUESTS: 429, INTERNAL_SERVER_ERROR: 500, SERVICE_UNAVAILABLE: 503, }; export const successCodes = [ httpStatusCodes.OK, - httpStatusCodes.CREATED, - httpStatusCodes.ACCEPTED, - httpStatusCodes.NON_AUTHORITATIVE_INFORMATION, - httpStatusCodes.NO_CONTENT, - httpStatusCodes.RESET_CONTENT, - httpStatusCodes.PARTIAL_CONTENT, - httpStatusCodes.MULTI_STATUS, - httpStatusCodes.ALREADY_REPORTED, - httpStatusCodes.IM_USED, + HTTP_STATUS_CREATED, + HTTP_STATUS_ACCEPTED, + HTTP_STATUS_NON_AUTHORITATIVE_INFORMATION, + HTTP_STATUS_NO_CONTENT, + HTTP_STATUS_RESET_CONTENT, + HTTP_STATUS_PARTIAL_CONTENT, + HTTP_STATUS_MULTI_STATUS, + HTTP_STATUS_ALREADY_REPORTED, + HTTP_STATUS_IM_USED, ]; export default httpStatusCodes; diff --git a/app/assets/javascripts/lib/utils/poll.js b/app/assets/javascripts/lib/utils/poll.js index 71782c9a4ce..73add1e37ee 100644 --- a/app/assets/javascripts/lib/utils/poll.js +++ b/app/assets/javascripts/lib/utils/poll.js @@ -1,5 +1,5 @@ import { normalizeHeaders } from './common_utils'; -import httpStatusCodes, { successCodes } from './http_status'; +import { HTTP_STATUS_ABORTED, successCodes } from './http_status'; /** * Polling utility for handling realtime updates. @@ -108,7 +108,7 @@ export default class Poll { }) .catch((error) => { notificationCallback(false); - if (error.status === httpStatusCodes.ABORTED) { + if (error.status === HTTP_STATUS_ABORTED) { return; } errorCallback(error); diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index b1a0baf8150..f33484f4192 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -86,7 +86,7 @@ export function cleanLeadingSeparator(path) { return path.replace(PATH_SEPARATOR_LEADING_REGEX, ''); } -function cleanEndingSeparator(path) { +export function cleanEndingSeparator(path) { return path.replace(PATH_SEPARATOR_ENDING_REGEX, ''); } diff --git a/app/assets/javascripts/listbox/index.js b/app/assets/javascripts/listbox/index.js index 7eacbf7fcdd..7e8fc4b637b 100644 --- a/app/assets/javascripts/listbox/index.js +++ b/app/assets/javascripts/listbox/index.js @@ -1,4 +1,4 @@ -import { GlListbox } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; @@ -31,7 +31,7 @@ export function initListbox(el, { onChange } = {}) { }, }, render(h) { - return h(GlListbox, { + return h(GlCollapsibleListbox, { props: { items, right, diff --git a/app/assets/javascripts/listbox/redirect_behavior.js b/app/assets/javascripts/listbox/redirect_behavior.js index 7e0ea2c4dfd..38d9d84f889 100644 --- a/app/assets/javascripts/listbox/redirect_behavior.js +++ b/app/assets/javascripts/listbox/redirect_behavior.js @@ -2,7 +2,7 @@ import { initListbox } from '~/listbox'; import { redirectTo } from '~/lib/utils/url_utility'; /** - * Instantiates GlListbox components with redirect behavior for tags created + * Instantiates GlCollapsibleListbox components with redirect behavior for tags created * with the `gl_redirect_listbox_tag` HAML helper. * * NOTE: Do not import this script explicitly. Using `gl_redirect_listbox_tag` diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 8e4ebd510aa..df3b55ed2ad 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -37,6 +37,7 @@ import initBroadcastNotifications from './broadcast_notification'; import { initTopNav } from './nav'; import { initCopyCodeButton } from './behaviors/copy_code'; import initHeaderSearch from './header_search/init'; +import initGitlabVersionCheck from './gitlab_version_check'; import 'ee_else_ce/main_ee'; import 'jh_else_ce/main_jh'; @@ -100,21 +101,7 @@ function deferredInitialisation() { initDefaultTrackers(); initFeatureHighlight(); initCopyCodeButton(); - - const helpToggle = document.querySelector('.header-help-dropdown-toggle'); - if (helpToggle) { - helpToggle.addEventListener( - 'click', - () => { - import(/* webpackChunkName: 'versionCheck' */ './gitlab_version_check') - .then(({ default: initGitlabVersionCheck }) => { - initGitlabVersionCheck(); - }) - .catch(() => {}); - }, - { once: true }, - ); - } + initGitlabVersionCheck(); addSelectOnFocusBehaviour('.js-select-on-focus'); diff --git a/app/assets/javascripts/members/components/avatars/user_avatar.vue b/app/assets/javascripts/members/components/avatars/user_avatar.vue index ec59f0f681c..4260ee14a14 100644 --- a/app/assets/javascripts/members/components/avatars/user_avatar.vue +++ b/app/assets/javascripts/members/components/avatars/user_avatar.vue @@ -1,10 +1,6 @@ <script> -import { - GlAvatarLink, - GlAvatarLabeled, - GlBadge, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; +import { GlAvatarLink, GlAvatarLabeled, GlBadge } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { generateBadges } from 'ee_else_ce/members/utils'; import { glEmojiTag } from '~/emoji'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue index cb7b963b698..76b286f94ad 100644 --- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue +++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue @@ -7,11 +7,11 @@ import { redirectTo, } from '~/lib/utils/url_utility'; import { - SEARCH_TOKEN_TYPE, SORT_QUERY_PARAM_NAME, ACTIVE_TAB_QUERY_PARAM_NAME, AVAILABLE_FILTERED_SEARCH_TOKENS, } from 'ee_else_ce/members/constants'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; export default { @@ -65,7 +65,7 @@ export default { if (query[this.filteredSearchBar.searchParam]) { tokens.push({ - type: SEARCH_TOKEN_TYPE, + type: FILTERED_SEARCH_TERM, value: { data: query[this.filteredSearchBar.searchParam], }, @@ -83,7 +83,7 @@ export default { return accumulator; } - if (type === SEARCH_TOKEN_TYPE) { + if (type === FILTERED_SEARCH_TERM) { if (value.data !== '') { const { searchParam } = this.filteredSearchBar; const { [searchParam]: searchParamValue } = accumulator; diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js index 3135ec602be..dab544c7cbc 100644 --- a/app/assets/javascripts/members/constants.js +++ b/app/assets/javascripts/members/constants.js @@ -1,7 +1,7 @@ import { GlFilteredSearchToken } from '@gitlab/ui'; import { __, s__ } from '~/locale'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; // Overridden in EE export const EE_APP_OPTIONS = {}; @@ -117,7 +117,7 @@ export const FILTERED_SEARCH_TOKEN_TWO_FACTOR = { title: s__('Members|2FA'), token: GlFilteredSearchToken, unique: true, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, options: [ { value: 'enabled', title: s__('Members|Enabled') }, { value: 'disabled', title: s__('Members|Disabled') }, @@ -131,7 +131,7 @@ export const FILTERED_SEARCH_TOKEN_WITH_INHERITED_PERMISSIONS = { title: s__('Members|Membership'), token: GlFilteredSearchToken, unique: true, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, options: [ { value: 'exclude', title: s__('Members|Direct') }, { value: 'only', title: s__('Members|Inherited') }, @@ -187,8 +187,6 @@ 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_QUERY_PARAM_NAME = 'sort'; export const ACTIVE_TAB_QUERY_PARAM_NAME = 'tab'; diff --git a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue index 87eeb272659..6c431dc8af3 100644 --- a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue +++ b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue @@ -1,6 +1,6 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { mapActions } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import syntaxHighlight from '~/syntax_highlight'; import { SYNTAX_HIGHLIGHT_CLASS } from '../constants'; import utilsMixin from '../mixins/line_conflict_utils'; diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue index 2c59e7bfa2f..f8a097a3a0f 100644 --- a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue +++ b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue @@ -1,6 +1,6 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { mapActions } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import syntaxHighlight from '~/syntax_highlight'; import { SYNTAX_HIGHLIGHT_CLASS } from '../constants'; import utilsMixin from '../mixins/line_conflict_utils'; diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 57b5e9809d2..80eb94a5364 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -94,7 +94,11 @@ MergeRequest.prototype.initMRBtnListeners = function () { .put(draftToggle.href, null, { params: { format: 'json' } }) .then(({ data }) => { draftToggle.removeAttribute('disabled'); - eventHub.$emit('MRWidgetUpdateRequested'); + + if (!window.gon?.features?.realtimeMrStatusChange) { + eventHub.$emit('MRWidgetUpdateRequested'); + } + MergeRequest.toggleDraftStatus(data.title, wipEvent === 'ready'); }) .catch(() => { @@ -173,7 +177,7 @@ MergeRequest.toggleDraftStatus = function (title, isReady) { ); draftToggle.setAttribute('href', url); - draftToggle.querySelector('.gl-new-dropdown-item-text-wrapper').textContent = isReady + draftToggle.querySelector('.gl-dropdown-item-text-wrapper').textContent = isReady ? __('Mark as draft') : __('Mark as ready'); }); diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 0ddf5def8ee..5a1410ceeba 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -5,6 +5,7 @@ import { createAlert } from '~/flash'; import { getCookie, isMetaClick, parseBoolean, scrollToElement } from '~/lib/utils/common_utils'; import { parseUrlPathname } from '~/lib/utils/url_utility'; import createEventHub from '~/helpers/event_hub_factory'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import BlobForkSuggestion from './blob/blob_fork_suggestion'; import Diff from './diff'; import { initDiffStatsDropdown } from './init_diff_stats_dropdown'; @@ -161,6 +162,23 @@ function toggleLoader(state) { $('.mr-loading-status .loading').toggleClass('hide', !state); } +function getActionFromHref(href) { + let action = new URL(href).pathname.match(/\/(commits|diffs|pipelines).*$/); + + if (action) { + action = action[0].replace(/(^\/|\.html)/g, ''); + } else { + action = 'show'; + } + + return action; +} + +const pageBundles = { + show: () => import(/* webpackPrefetch: true */ '~/mr_notes/init_notes'), + diffs: () => import(/* webpackPrefetch: true */ '~/diffs'), +}; + export default class MergeRequestTabs { constructor({ action, setUrl, stubLocation } = {}) { this.mergeRequestTabs = document.querySelector('.merge-request-tabs-container'); @@ -186,10 +204,10 @@ export default class MergeRequestTabs { this.currentTab = null; this.diffsLoaded = false; - this.pipelinesLoaded = false; this.commitsLoaded = false; this.fixedLayoutPref = null; this.eventHub = createEventHub(); + this.loadedPages = { [action]: true }; this.setUrl = setUrl !== undefined ? setUrl : true; this.setCurrentAction = this.setCurrentAction.bind(this); @@ -206,12 +224,11 @@ export default class MergeRequestTabs { bindEvents() { $('.merge-request-tabs a[data-toggle="tabvue"]').on('click', this.clickTab); - window.addEventListener('popstate', (event) => { - if (event.state && event.state.action) { - this.tabShown(event.state.action, event.target.location); - this.currentAction = event.state.action; - this.eventHub.$emit('MergeRequestTabChange', this.getCurrentAction()); - } + window.addEventListener('popstate', () => { + const action = getActionFromHref(location.href); + + this.tabShown(action, location.href); + this.eventHub.$emit('MergeRequestTabChange', action); }); } @@ -252,17 +269,18 @@ export default class MergeRequestTabs { } else if (action) { const href = e.currentTarget.getAttribute('href'); this.tabShown(action, href); - - if (this.setUrl) { - this.setCurrentAction(action); - } } } } tabShown(action, href, shouldScroll = true) { + toggleLoader(false); + if (action !== this.currentTab && this.mergeRequestTabs) { this.currentTab = action; + if (this.setUrl) { + this.setCurrentAction(action); + } if (this.mergeRequestTabPanesAll) { this.mergeRequestTabPanesAll.forEach((el) => { @@ -282,6 +300,20 @@ export default class MergeRequestTabs { const tab = this.mergeRequestTabs.querySelector(`.${action}-tab`); if (tab) tab.classList.add('active'); + if (!this.loadedPages[action] && action in pageBundles) { + toggleLoader(true); + pageBundles[action]() + .then(({ default: init }) => { + toggleLoader(false); + init(); + this.loadedPages[action] = true; + }) + .catch(() => { + toggleLoader(false); + createAlert({ message: __('MergeRequest|Failed to load the page') }); + }); + } + if (window.gon?.features?.movedMrSidebar) { this.expandSidebar?.forEach((el) => el.classList.toggle('gl-display-none!', action !== 'show'), @@ -334,7 +366,7 @@ export default class MergeRequestTabs { this.commitPipelinesTable = destroyPipelines(this.commitPipelinesTable); } - $('.detail-page-description').renderGFM(); + renderGFM(document.querySelector('.detail-page-description')); if (shouldScroll) this.recallScroll(action); } else if (action === this.currentAction) { @@ -398,7 +430,7 @@ export default class MergeRequestTabs { // Ensure parameters and hash come along for the ride newState += location.search + location.hash; - if (window.history.state && window.history.state.url && window.location.pathname !== newState) { + if (window.location.pathname !== newState) { window.history.pushState( { url: newState, @@ -477,8 +509,6 @@ export default class MergeRequestTabs { return; } - toggleLoader(true); - loadDiffs({ // We extract pathname for the current Changes tab anchor href // some pages like MergeRequestsController#new has query parameters on that anchor @@ -496,9 +526,6 @@ export default class MergeRequestTabs { createAlert({ message: __('An error occurred while fetching this tab.'), }); - }) - .finally(() => { - toggleLoader(false); }); } diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue index b7629ba001f..4a675cf7563 100644 --- a/app/assets/javascripts/merge_requests/components/sticky_header.vue +++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue @@ -1,12 +1,7 @@ <script> -import { - GlIntersectionObserver, - GlLink, - GlSprintf, - GlBadge, - GlSafeHtmlDirective, -} from '@gitlab/ui'; +import { GlIntersectionObserver, GlLink, GlSprintf, GlBadge } from '@gitlab/ui'; import { mapGetters, mapState } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -28,7 +23,7 @@ export default { ClipboardButton, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [glFeatureFlagsMixin()], inject: { diff --git a/app/assets/javascripts/merge_requests/components/target_project_dropdown.vue b/app/assets/javascripts/merge_requests/components/target_project_dropdown.vue new file mode 100644 index 00000000000..cd2e25793f4 --- /dev/null +++ b/app/assets/javascripts/merge_requests/components/target_project_dropdown.vue @@ -0,0 +1,87 @@ +<script> +import { GlListbox } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import { createAlert } from '~/flash'; +import { __ } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; + +export default { + components: { + GlListbox, + }, + inject: { + targetProjectsPath: { + type: String, + required: true, + }, + currentProject: { + type: Object, + required: true, + }, + }, + data() { + return { + currentProject: this.currentProject, + selected: this.currentProject.value, + isLoading: false, + projects: [], + }; + }, + methods: { + async fetchProjects(search = '') { + this.isLoading = true; + + try { + const { data } = await axios.get(this.targetProjectsPath, { + params: { search }, + }); + + this.projects = data.map((p) => ({ + value: `${p.id}`, + text: p.full_path.replace(/^\//, ''), + refsUrl: p.refs_url, + })); + this.isLoading = false; + } catch { + createAlert({ + message: __('Error fetching target projects. Please try again.'), + primaryButton: { text: __('Try again'), clickHandler: () => this.fetchProjects(search) }, + }); + } + }, + searchProjects: debounce(function searchProjects(search) { + this.fetchProjects(search); + }, 500), + selectProject(projectId) { + this.currentProject = this.projects.find((p) => p.value === projectId); + + this.$emit('project-selected', this.currentProject.refsUrl); + }, + }, +}; +</script> + +<template> + <div> + <input + id="merge_request_target_project_id" + type="hidden" + :value="currentProject.value" + name="merge_request[target_project_id]" + data-testid="target-project-input" + /> + <gl-listbox + v-model="selected" + :items="projects" + :toggle-text="currentProject.text" + :header-text="__('Select target project')" + :searching="isLoading" + searchable + class="gl-w-full dropdown-target-project" + toggle-class="gl-align-items-flex-start! gl-justify-content-start! mr-compare-dropdown js-target-project" + @shown="fetchProjects" + @search="searchProjects" + @select="selectProject" + /> + </div> +</template> diff --git a/app/assets/javascripts/ml/experiment_tracking/components/experiment.vue b/app/assets/javascripts/ml/experiment_tracking/components/experiment.vue deleted file mode 100644 index 73cdfbc44b0..00000000000 --- a/app/assets/javascripts/ml/experiment_tracking/components/experiment.vue +++ /dev/null @@ -1,36 +0,0 @@ -<script> -import { GlTable } from '@gitlab/ui'; -import IncubationAlert from './incubation_alert.vue'; - -export default { - name: 'ShowMlExperiment', - components: { - GlTable, - IncubationAlert, - }, - inject: ['candidates', 'metricNames', 'paramNames'], - computed: { - fields() { - return [...this.paramNames, ...this.metricNames]; - }, - }, -}; -</script> - -<template> - <div> - <incubation-alert /> - - <h3> - {{ __('Experiment Candidates') }} - </h3> - - <gl-table - :fields="fields" - :items="candidates" - :empty-text="__('This Experiment has no logged Candidates')" - show-empty - class="gl-mt-0!" - /> - </div> -</template> diff --git a/app/assets/javascripts/ml/experiment_tracking/components/incubation_alert.vue b/app/assets/javascripts/ml/experiment_tracking/components/incubation_alert.vue index 51c1e935677..42f6394ed68 100644 --- a/app/assets/javascripts/ml/experiment_tracking/components/incubation_alert.vue +++ b/app/assets/javascripts/ml/experiment_tracking/components/incubation_alert.vue @@ -8,8 +8,8 @@ export default { contentLabel: __( 'GitLab incubates features to explore new use cases. These features are updated regularly, and support is limited', ), - learnMoreLabel: __('Learn More'), - feedbackLabel: __('Feedback and Updates'), + learnMoreLabel: __('Learn more'), + feedbackLabel: __('Feedback'), }, name: 'MlopsIncubationAlert', components: { GlAlert, GlLink }, @@ -37,7 +37,7 @@ export default { :title="$options.i18n.titleLabel" variant="warning" :primary-button-text="$options.i18n.feedbackLabel" - primary-button-link="https://gitlab.com/groups/gitlab-org/-/epics/8560" + primary-button-link="https://gitlab.com/gitlab-org/gitlab/-/issues/381660" @dismiss="dismissAlert" > {{ $options.i18n.contentLabel }} diff --git a/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue b/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue new file mode 100644 index 00000000000..5f54f24e24c --- /dev/null +++ b/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue @@ -0,0 +1,94 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import { __ } from '~/locale'; +import IncubationAlert from './incubation_alert.vue'; + +export default { + name: 'MlCandidate', + components: { + IncubationAlert, + GlLink, + }, + inject: ['candidate'], + i18n: { + titleLabel: __('Model candidate details'), + infoLabel: __('Info'), + idLabel: __('ID'), + statusLabel: __('Status'), + experimentLabel: __('Experiment'), + artifactsLabel: __('Artifacts'), + parametersLabel: __('Parameters'), + metricsLabel: __('Metrics'), + }, +}; +</script> + +<template> + <div> + <incubation-alert /> + + <h3> + {{ $options.i18n.titleLabel }} + </h3> + + <table class="candidate-details"> + <tbody> + <tr class="divider"></tr> + + <tr> + <td class="gl-text-secondary gl-font-weight-bold">{{ $options.i18n.infoLabel }}</td> + <td class="gl-font-weight-bold">{{ $options.i18n.idLabel }}</td> + <td>{{ candidate.info.iid }}</td> + </tr> + + <tr> + <td></td> + <td class="gl-font-weight-bold">{{ $options.i18n.statusLabel }}</td> + <td>{{ candidate.info.status }}</td> + </tr> + + <tr> + <td></td> + <td class="gl-font-weight-bold">{{ $options.i18n.experimentLabel }}</td> + <td> + <gl-link :href="candidate.info.path_to_experiment">{{ + candidate.info.experiment_name + }}</gl-link> + </td> + </tr> + + <tr v-if="candidate.info.path_to_artifact"> + <td></td> + <td class="gl-font-weight-bold">{{ $options.i18n.artifactsLabel }}</td> + <td> + <gl-link :href="candidate.info.path_to_artifact">{{ + $options.i18n.artifactsLabel + }}</gl-link> + </td> + </tr> + + <tr class="divider"></tr> + + <tr v-for="(param, index) in candidate.params" :key="param.name"> + <td v-if="index == 0" class="gl-text-secondary gl-font-weight-bold"> + {{ $options.i18n.parametersLabel }} + </td> + <td v-else></td> + <td class="gl-font-weight-bold">{{ param.name }}</td> + <td>{{ param.value }}</td> + </tr> + + <tr class="divider"></tr> + + <tr v-for="(metric, index) in candidate.metrics" :key="metric.name"> + <td v-if="index == 0" class="gl-text-secondary gl-font-weight-bold"> + {{ $options.i18n.metricsLabel }} + </td> + <td v-else></td> + <td class="gl-font-weight-bold">{{ metric.name }}</td> + <td>{{ metric.value }}</td> + </tr> + </tbody> + </table> + </div> +</template> diff --git a/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue b/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue new file mode 100644 index 00000000000..f8e269d3b57 --- /dev/null +++ b/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue @@ -0,0 +1,59 @@ +<script> +import { GlTable, GlLink } from '@gitlab/ui'; +import { __ } from '~/locale'; +import IncubationAlert from './incubation_alert.vue'; + +export default { + name: 'MlExperiment', + components: { + GlTable, + GlLink, + IncubationAlert, + }, + inject: ['candidates', 'metricNames', 'paramNames'], + computed: { + fields() { + return [ + ...this.paramNames, + ...this.metricNames, + { key: 'details', label: '' }, + { key: 'artifact', label: '' }, + ]; + }, + }, + i18n: { + titleLabel: __('Experiment candidates'), + emptyStateLabel: __('This experiment has no logged candidates'), + artifactsLabel: __('Artifacts'), + detailsLabel: __('Details'), + }, +}; +</script> + +<template> + <div> + <incubation-alert /> + + <h3> + {{ $options.i18n.titleLabel }} + </h3> + + <gl-table + :fields="fields" + :items="candidates" + :empty-text="$options.i18n.emptyStateLabel" + show-empty + class="gl-mt-0!" + > + <template #cell(artifact)="data"> + <gl-link v-if="data.value" :href="data.value" target="_blank">{{ + $options.i18n.artifactsLabel + }}</gl-link> + </template> + + <template #cell(details)="data"> + <gl-link :href="data.value">{{ $options.i18n.detailsLabel }}</gl-link> + </template> + </gl-table> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue index ae079da0b0b..da4c92df711 100644 --- a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue +++ b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue @@ -1,11 +1,11 @@ <script> import chartEmptyStateIllustration from '@gitlab/svgs/dist/illustrations/chart-empty-state.svg'; -import { GlSafeHtmlDirective } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { chartHeight } from '../../constants'; export default { directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, data() { return { diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index b6ad2d21757..2c185794d17 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -391,11 +391,7 @@ export default { }; </script> <template> - <div - class="prometheus-graphs" - data-qa-selector="prometheus_graphs_content" - data-testid="prometheus-graphs" - > + <div class="prometheus-graphs" data-testid="prometheus-graphs"> <div> <gl-alert v-if="!isDeprecationNoticeDismissed" diff --git a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue index 7f8fb3c223d..d67154b7697 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue @@ -146,7 +146,6 @@ export default { <gl-dropdown v-gl-tooltip data-testid="actions-menu" - data-qa-selector="actions_menu_dropdown" right no-caret toggle-class="gl-px-3!" @@ -223,7 +222,6 @@ export default { <gl-dropdown-item v-if="isMenuItemEnabled.editDashboard" :href="selectedDashboard ? selectedDashboard.project_blob_path : null" - data-qa-selector="edit_dashboard_button_enabled" data-testid="edit-dashboard-item-enabled" > {{ $options.i18n.editDashboard }} diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue index 90d2498ac19..7bb0d3874d1 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_header.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue @@ -173,7 +173,6 @@ export default { <div class="gl-mb-3 gl-mr-3 gl-display-flex gl-sm-display-block"> <dashboards-dropdown id="monitor-dashboards-dropdown" - data-qa-selector="dashboards_filter_dropdown" class="flex-grow-1" toggle-class="dropdown-menu-toggle" :default-branch="defaultBranch" @@ -188,7 +187,6 @@ export default { id="monitor-environments-dropdown" ref="monitorEnvironmentsDropdown" class="flex-grow-1" - data-qa-selector="environments_dropdown" data-testid="environments-dropdown" toggle-class="dropdown-menu-toggle" menu-class="monitor-environment-dropdown-menu" @@ -225,7 +223,6 @@ export default { <date-time-picker ref="dateTimePicker" class="flex-grow-1 show-last-dropdown" - data-qa-selector="range_picker_dropdown" :value="selectedTimeRange" :options="$options.timeRanges" :utc="displayUtc" diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue index 7e7dcef7639..9ad6da35d6b 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue @@ -292,11 +292,7 @@ export default { <div v-if="graphDataIsLoading" class="mx-1 mt-1"> <gl-loading-icon size="sm" /> </div> - <div - v-if="isContextualMenuShown" - ref="contextualMenu" - data-qa-selector="prometheus_graph_widgets" - > + <div v-if="isContextualMenuShown" ref="contextualMenu"> <div data-testid="dropdown-wrapper" class="d-flex align-items-center"> <!-- This component should be replaced with a variant developed @@ -310,7 +306,6 @@ export default { :text-sr-only="true" toggle-class="gl-px-3!" no-caret - data-qa-selector="prometheus_widgets_dropdown" right :title="__('More actions')" > @@ -339,7 +334,6 @@ export default { ref="copyChartLink" v-track-event="generateLinkToChartOptions(clipboardText)" :data-clipboard-text="clipboardText" - data-qa-selector="generate_chart_link_menu_item" @click="showToast(clipboardText)" > {{ __('Copy link to chart') }} diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue index 8efea2bfc3e..e8a9c24f5c2 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue @@ -100,7 +100,7 @@ export default { <gl-form-textarea id="panel-yml-input" v-model="yml" - class="gl-h-200! gl-font-monospace! gl-font-size-monospace!" + class="gl-h-200! gl-font-monospace!" /> </gl-form-group> <div class="gl-text-right"> diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue index a63008aa382..9ad14b3d52e 100644 --- a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue +++ b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue @@ -104,13 +104,7 @@ export default { label-size="sm" label-for="fileName" > - <gl-form-input - id="fileName" - ref="fileName" - v-model="form.fileName" - data-qa-selector="duplicate_dashboard_filename_field" - :required="true" - /> + <gl-form-input id="fileName" ref="fileName" v-model="form.fileName" :required="true" /> </gl-form-group> <gl-form-group :label="__('Branch')" label-size="sm" label-for="branch"> <gl-form-radio-group diff --git a/app/assets/javascripts/monitoring/components/group_empty_state.vue b/app/assets/javascripts/monitoring/components/group_empty_state.vue index 0365fc66331..a67770b93be 100644 --- a/app/assets/javascripts/monitoring/components/group_empty_state.vue +++ b/app/assets/javascripts/monitoring/components/group_empty_state.vue @@ -1,5 +1,6 @@ <script> -import { GlEmptyState, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlEmptyState } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __, sprintf } from '~/locale'; import { metricStates } from '../constants'; diff --git a/app/assets/javascripts/monitoring/components/refresh_button.vue b/app/assets/javascripts/monitoring/components/refresh_button.vue index 544fe10f26e..55c602db33d 100644 --- a/app/assets/javascripts/monitoring/components/refresh_button.vue +++ b/app/assets/javascripts/monitoring/components/refresh_button.vue @@ -11,8 +11,6 @@ import Visibility from 'visibilityjs'; import { mapActions } from 'vuex'; import { n__, __, s__ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; - const makeInterval = (length = 0, unit = 's') => { const shortLabel = `${length}${unit}`; switch (unit) { @@ -58,7 +56,6 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagsMixin()], data() { return { refreshInterval: null, @@ -66,12 +63,6 @@ export default { }; }, computed: { - disableMetricDashboardRefreshRate() { - // Can refresh rates impact performance? - // Add "negative" feature flag called `disable_metric_dashboard_refresh_rate` - // See more at: https://gitlab.com/gitlab-org/gitlab/-/issues/229831 - return this.glFeatures.disableMetricDashboardRefreshRate; - }, dropdownText() { return this.refreshInterval?.shortLabel ?? __('Off'); }, @@ -156,12 +147,7 @@ export default { icon="retry" @click="refresh" /> - <gl-dropdown - v-if="!disableMetricDashboardRefreshRate" - v-gl-tooltip - :title="s__('Metrics|Set refresh rate')" - :text="dropdownText" - > + <gl-dropdown v-gl-tooltip :title="s__('Metrics|Set refresh rate')" :text="dropdownText"> <gl-dropdown-item is-check-item :is-checked="refreshInterval === null" diff --git a/app/assets/javascripts/monitoring/components/variables_section.vue b/app/assets/javascripts/monitoring/components/variables_section.vue index 493d37ce263..971f188e9f3 100644 --- a/app/assets/javascripts/monitoring/components/variables_section.vue +++ b/app/assets/javascripts/monitoring/components/variables_section.vue @@ -37,11 +37,7 @@ export default { }; </script> <template> - <div - ref="variablesSection" - class="d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 variables-section" - data-qa-selector="variables_content" - > + <div ref="variablesSection" class="d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 variables-section"> <div v-for="variable in variables" :key="variable.name" class="mb-1 pr-2 d-flex d-sm-block"> <component :is="variableField(variable.type)" @@ -50,7 +46,6 @@ export default { :value="variable.value" :name="variable.name" :options="variable.options" - data-qa-selector="variable_item" @input="refreshDashboard(variable, $event)" /> </div> diff --git a/app/assets/javascripts/monitoring/csv_export.js b/app/assets/javascripts/monitoring/csv_export.js index eaeed4a54d4..7e15b659767 100644 --- a/app/assets/javascripts/monitoring/csv_export.js +++ b/app/assets/javascripts/monitoring/csv_export.js @@ -110,7 +110,7 @@ const csvData = (metricHeaders, metricValues) => { // "If double-quotes are used to enclose fields, then a double-quote // appearing inside a field must be escaped by preceding it with // another double quote." - // https://tools.ietf.org/html/rfc4180#page-2 + // https://www.rfc-editor.org/rfc/rfc4180#page-2 const headers = metricHeaders.map((header) => `"${header.replace(/"/g, '""')}"`); return { diff --git a/app/assets/javascripts/monitoring/requests/index.js b/app/assets/javascripts/monitoring/requests/index.js index 26fedb9c81c..8b65eec051f 100644 --- a/app/assets/javascripts/monitoring/requests/index.js +++ b/app/assets/javascripts/monitoring/requests/index.js @@ -1,13 +1,16 @@ import axios from '~/lib/utils/axios_utils'; import { backOff } from '~/lib/utils/common_utils'; -import statusCodes from '~/lib/utils/http_status'; +import statusCodes, { + HTTP_STATUS_NO_CONTENT, + HTTP_STATUS_UNPROCESSABLE_ENTITY, +} from '~/lib/utils/http_status'; import { PROMETHEUS_TIMEOUT } from '../constants'; const cancellableBackOffRequest = (makeRequestCallback) => backOff((next, stop) => { makeRequestCallback() .then((resp) => { - if (resp.status === statusCodes.NO_CONTENT) { + if (resp.status === HTTP_STATUS_NO_CONTENT) { next(); } else { stop(resp); @@ -34,7 +37,7 @@ export const getPrometheusQueryData = (prometheusEndpoint, params, opts) => const { response = {} } = error; if ( response.status === statusCodes.BAD_REQUEST || - response.status === statusCodes.UNPROCESSABLE_ENTITY || + response.status === HTTP_STATUS_UNPROCESSABLE_ENTITY || response.status === statusCodes.SERVICE_UNAVAILABLE ) { const { data } = response; diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index fd8749625da..0d849e1a2d8 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -39,7 +39,6 @@ export const stateAndPropsFromDataset = (dataset = {}) => { // HTML attributes are always strings, parse other types. dataProps.hasMetrics = parseBoolean(dataProps.hasMetrics); dataProps.customMetricsAvailable = parseBoolean(dataProps.customMetricsAvailable); - dataProps.prometheusAlertsAvailable = parseBoolean(dataProps.prometheusAlertsAvailable); return { initState: { diff --git a/app/assets/javascripts/mr_notes/discussion_counter.js b/app/assets/javascripts/mr_notes/discussion_counter.js new file mode 100644 index 00000000000..0bb63a7c0f9 --- /dev/null +++ b/app/assets/javascripts/mr_notes/discussion_counter.js @@ -0,0 +1,28 @@ +import Vue from 'vue'; +import DiscussionCounter from '~/notes/components/discussion_counter.vue'; +import store from '~/mr_notes/stores'; + +export function initDiscussionCounter() { + const el = document.getElementById('js-vue-discussion-counter'); + + if (el) { + const { blocksMerge } = el.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el, + name: 'DiscussionCounter', + components: { + DiscussionCounter, + }, + store, + render(createElement) { + return createElement('discussion-counter', { + props: { + blocksMerge: blocksMerge === 'true', + }, + }); + }, + }); + } +} diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index c32a1f4c2ac..a202923bd21 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -1,12 +1,8 @@ -import Vue from 'vue'; -import store from '~/mr_notes/stores'; import initCherryPickCommitModal from '~/projects/commit/init_cherry_pick_commit_modal'; import initRevertCommitModal from '~/projects/commit/init_revert_commit_modal'; -import initDiffsApp from '../diffs'; -import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; +import { initMrStateLazyLoad } from '~/mr_notes/init'; import MergeRequest from '../merge_request'; -import DiscussionCounter from '../notes/components/discussion_counter.vue'; -import initNotesApp from './init_notes'; +import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; export default function initMrNotes() { resetServiceWorkersPublicPath(); @@ -17,36 +13,10 @@ export default function initMrNotes() { action: mrShowNode.dataset.mrAction, }); - initDiffsApp(store); - initNotesApp(); + initMrStateLazyLoad(); document.addEventListener('merged:UpdateActions', () => { initRevertCommitModal('i_code_review_post_merge_submit_revert_modal'); initCherryPickCommitModal('i_code_review_post_merge_submit_cherry_pick_modal'); }); - - requestIdleCallback(() => { - const el = document.getElementById('js-vue-discussion-counter'); - - if (el) { - const { blocksMerge } = el.dataset; - - // eslint-disable-next-line no-new - new Vue({ - el, - name: 'DiscussionCounter', - components: { - DiscussionCounter, - }, - store, - render(createElement) { - return createElement('discussion-counter', { - props: { - blocksMerge: blocksMerge === 'true', - }, - }); - }, - }); - } - }); } diff --git a/app/assets/javascripts/mr_notes/init.js b/app/assets/javascripts/mr_notes/init.js new file mode 100644 index 00000000000..aab3c41b4cf --- /dev/null +++ b/app/assets/javascripts/mr_notes/init.js @@ -0,0 +1,52 @@ +import { parseBoolean } from '~/lib/utils/common_utils'; +import store from '~/mr_notes/stores'; +import { getLocationHash } from '~/lib/utils/url_utility'; +import eventHub from '~/notes/event_hub'; +import { initReviewBar } from '~/batch_comments'; +import { initDiscussionCounter } from '~/mr_notes/discussion_counter'; +import { initOverviewTabCounter } from '~/mr_notes/init_count'; + +function setupMrNotesState(notesDataset) { + const noteableData = JSON.parse(notesDataset.noteableData); + noteableData.noteableType = notesDataset.noteableType; + noteableData.targetType = notesDataset.targetType; + noteableData.discussion_locked = parseBoolean(notesDataset.isLocked); + const notesData = JSON.parse(notesDataset.notesData); + const currentUserData = JSON.parse(notesDataset.currentUserData); + const endpoints = { metadata: notesDataset.endpointMetadata }; + + store.dispatch('setNotesData', notesData); + store.dispatch('setNoteableData', noteableData); + store.dispatch('setUserData', currentUserData); + store.dispatch('setTargetNoteHash', getLocationHash()); + store.dispatch('setEndpoints', endpoints); + eventHub.$once('fetchNotesData', () => store.dispatch('fetchNotes')); +} + +export function initMrStateLazyLoad() { + store.dispatch('setActiveTab', window.mrTabs.getCurrentAction()); + window.mrTabs.eventHub.$on('MergeRequestTabChange', (value) => + store.dispatch('setActiveTab', value), + ); + + const discussionsEl = document.getElementById('js-vue-mr-discussions'); + const notesDataset = discussionsEl.dataset; + let stop = () => {}; + stop = store.watch( + (state) => state.page.activeTab, + (activeTab) => { + // prevent loading MR state on commits and pipelines pages + // this is due to them having a shared controller with the Overview page + if (['diffs', 'show'].includes(activeTab)) { + setupMrNotesState(notesDataset); + requestIdleCallback(() => { + initReviewBar(); + initOverviewTabCounter(); + initDiscussionCounter(); + }); + stop(); + } + }, + { immediate: true }, + ); +} diff --git a/app/assets/javascripts/mr_notes/init_count.js b/app/assets/javascripts/mr_notes/init_count.js new file mode 100644 index 00000000000..3e924ebd9d5 --- /dev/null +++ b/app/assets/javascripts/mr_notes/init_count.js @@ -0,0 +1,13 @@ +import store from '~/mr_notes/stores'; + +export function initOverviewTabCounter() { + const discussionsCount = document.querySelector('.js-discussions-count'); + store.watch( + (state, getters) => getters.discussionTabCounter, + (val) => { + if (typeof val !== 'undefined') { + discussionsCount.textContent = val; + } + }, + ); +} diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js index 3a67e7925c3..e10605609b0 100644 --- a/app/assets/javascripts/mr_notes/init_notes.js +++ b/app/assets/javascripts/mr_notes/init_notes.js @@ -1,8 +1,8 @@ -import $ from 'jquery'; import Vue from 'vue'; import { mapActions, mapState, mapGetters } from 'vuex'; import { parseBoolean } from '~/lib/utils/common_utils'; import store from '~/mr_notes/stores'; +import notesEventHub from '~/notes/event_hub'; import discussionNavigator from '../notes/components/discussion_navigator.vue'; import NotesApp from '../notes/components/notes_app.vue'; import { getNotesFilterData } from '../notes/utils/get_notes_filter_data'; @@ -36,13 +36,12 @@ export default () => { endpoints: { metadata: notesDataset.endpointMetadata, }, - currentUserData: JSON.parse(notesDataset.currentUserData), notesData: JSON.parse(notesDataset.notesData), helpPagePath: notesDataset.helpPagePath, }; }, computed: { - ...mapGetters(['discussionTabCounter']), + ...mapGetters(['isNotesFetched']), ...mapState({ activeTab: (state) => state.page.activeTab, }), @@ -51,15 +50,6 @@ export default () => { }, }, watch: { - discussionTabCounter() { - if (window.gon?.features?.paginatedMrDiscussions) { - if (this.$store.state.notes.doneFetchingBatchDiscussions) { - this.updateDiscussionTabCounter(); - } - } else { - this.updateDiscussionTabCounter(); - } - }, isShowTabActive: { handler(newVal) { if (newVal) { @@ -70,25 +60,16 @@ export default () => { }, }, created() { - this.setActiveTab(window.mrTabs.getCurrentAction()); this.setEndpoints(this.endpoints); + if (!this.isNotesFetched) { + notesEventHub.$emit('fetchNotesData'); + } + this.fetchMrMetadata(); }, - mounted() { - this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge'); - $(document).on('visibilitychange', this.updateDiscussionTabCounter); - window.mrTabs.eventHub.$on('MergeRequestTabChange', this.setActiveTab); - }, - beforeDestroy() { - $(document).off('visibilitychange', this.updateDiscussionTabCounter); - window.mrTabs.eventHub.$off('MergeRequestTabChange', this.setActiveTab); - }, methods: { - ...mapActions(['setActiveTab', 'setEndpoints', 'fetchMrMetadata']), - updateDiscussionTabCounter() { - this.notesCountBadge.text(this.discussionTabCounter); - }, + ...mapActions(['setEndpoints', 'fetchMrMetadata']), }, render(createElement) { // NOTE: Even though `discussionNavigator` is added to the `notes-app`, diff --git a/app/assets/javascripts/nav/components/new_nav_toggle.vue b/app/assets/javascripts/nav/components/new_nav_toggle.vue new file mode 100644 index 00000000000..ef59140115d --- /dev/null +++ b/app/assets/javascripts/nav/components/new_nav_toggle.vue @@ -0,0 +1,71 @@ +<script> +import { GlBadge, GlToggle } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { createAlert } from '~/flash'; +import { s__ } from '~/locale'; + +export default { + i18n: { + badgeLabel: s__('NorthstarNavigation|Alpha'), + sectionTitle: s__('NorthstarNavigation|Navigation redesign'), + toggleMenuItemLabel: s__('NorthstarNavigation|New navigation'), + toggleLabel: s__('NorthstarNavigation|Toggle new navigation'), + updateError: s__( + 'NorthstarNavigation|Could not update the new navigation preference. Please try again later.', + ), + }, + components: { + GlBadge, + GlToggle, + }, + props: { + enabled: { + type: Boolean, + required: true, + }, + endpoint: { + type: String, + required: true, + }, + }, + data() { + return { + isEnabled: this.enabled, + }; + }, + methods: { + async toggleNav() { + try { + await axios.put(this.endpoint, { user: { use_new_navigation: !this.enabled } }); + window.location.reload(); + } catch (error) { + createAlert({ + message: this.$options.i18n.updateError, + error, + }); + } + }, + }, +}; +</script> + +<template> + <li> + <div + class="gl-px-4 gl-py-2 gl-display-flex gl-justify-content-space-between gl-align-items-center" + > + <b>{{ $options.i18n.sectionTitle }}</b> + <gl-badge>{{ $options.i18n.badgeLabel }}</gl-badge> + </div> + + <div class="menu-item gl-display-flex! gl-justify-content-space-between gl-align-items-center"> + {{ $options.i18n.toggleMenuItemLabel }} + <gl-toggle + v-model="isEnabled" + :label="$options.i18n.toggleLabel" + label-position="hidden" + @change="toggleNav" + /> + </div> + </li> +</template> diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js index ef36e58374c..a7c2e572037 100644 --- a/app/assets/javascripts/new_branch_form.js +++ b/app/assets/javascripts/new_branch_form.js @@ -1,15 +1,9 @@ /* eslint-disable func-names, no-return-assign, @gitlab/require-i18n-strings */ - -import $ from 'jquery'; -import RefSelectDropdown from './ref_select_dropdown'; - export default class NewBranchForm { - constructor(form, availableRefs) { + constructor(form) { this.validate = this.validate.bind(this); this.branchNameError = form.querySelector('.js-branch-name-error'); this.name = form.querySelector('.js-branch-name'); - this.ref = form.querySelector('#ref'); - new RefSelectDropdown($('.js-branch-select'), availableRefs); // eslint-disable-line no-new this.setupRestrictions(); this.addBinding(); this.init(); diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue index 9aa6abd9d8c..2caa93c3c93 100644 --- a/app/assets/javascripts/notebook/cells/markdown.vue +++ b/app/assets/javascripts/notebook/cells/markdown.vue @@ -1,7 +1,7 @@ <script> import katex from 'katex'; import { marked } from 'marked'; -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { sanitize } from '~/lib/dompurify'; import { hasContent, markdownConfig } from '~/lib/utils/text_utility'; import Prompt from './prompt.vue'; diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue index 5437a607e8a..74a5dd3806d 100644 --- a/app/assets/javascripts/notebook/cells/output/html.vue +++ b/app/assets/javascripts/notebook/cells/output/html.vue @@ -1,14 +1,10 @@ <script> -import { GlSafeHtmlDirective } from '@gitlab/ui'; import Prompt from '../prompt.vue'; export default { components: { Prompt, }, - directives: { - SafeHtml: GlSafeHtmlDirective, - }, props: { count: { type: Number, @@ -28,12 +24,6 @@ export default { return this.index === 0; }, }, - safeHtmlConfig: { - ADD_TAGS: ['use'], // to support icon SVGs - FORBID_TAGS: ['style'], - FORBID_ATTR: ['style'], - ALLOW_DATA_ATTR: false, - }, }; </script> diff --git a/app/assets/javascripts/notebook/cells/output/latex.vue b/app/assets/javascripts/notebook/cells/output/latex.vue index d0ed963b55d..55f97fee3dc 100644 --- a/app/assets/javascripts/notebook/cells/output/latex.vue +++ b/app/assets/javascripts/notebook/cells/output/latex.vue @@ -1,5 +1,5 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import 'mathjax/es5/tex-svg'; import Prompt from '../prompt.vue'; diff --git a/app/assets/javascripts/notebook/cells/output/markdown.vue b/app/assets/javascripts/notebook/cells/output/markdown.vue index 5da057dee72..ad74e28ac74 100644 --- a/app/assets/javascripts/notebook/cells/output/markdown.vue +++ b/app/assets/javascripts/notebook/cells/output/markdown.vue @@ -1,5 +1,4 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import Prompt from '../prompt.vue'; import Markdown from '../markdown.vue'; @@ -9,9 +8,6 @@ export default { Prompt, Markdown, }, - directives: { - SafeHtml, - }, props: { count: { type: Number, diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 0d7ff022f8f..2ccb9a0b514 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -7,7 +7,7 @@ import Autosave from '~/autosave'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { createAlert } from '~/flash'; import { badgeState } from '~/issuable/components/status_box.vue'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; import { capitalizeFirstCharacter, convertToCamelCase, @@ -28,8 +28,6 @@ import CommentTypeDropdown from './comment_type_dropdown.vue'; import DiscussionLockedWidget from './discussion_locked_widget.vue'; import NoteSignedOutWidget from './note_signed_out_widget.vue'; -const { UNPROCESSABLE_ENTITY } = httpStatusCodes; - export default { name: 'CommentForm', i18n: COMMENT_FORM, @@ -198,7 +196,7 @@ export default { 'toggleIssueLocalState', ]), handleSaveError({ data, status }) { - if (status === UNPROCESSABLE_ENTITY && data.errors?.commands_only?.length) { + if (status === HTTP_STATUS_UNPROCESSABLE_ENTITY && data.errors?.commands_only?.length) { this.errors = data.errors.commands_only; } else { this.errors = [this.$options.i18n.GENERIC_UNSUBMITTABLE_NETWORK]; diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue index cf6474270a2..f949142d90a 100644 --- a/app/assets/javascripts/notes/components/diff_discussion_header.vue +++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue @@ -1,7 +1,8 @@ <script> -import { GlSafeHtmlDirective as SafeHtml, GlAvatar, GlAvatarLink } from '@gitlab/ui'; +import { GlAvatar, GlAvatarLink } from '@gitlab/ui'; import { escape } from 'lodash'; import { mapActions } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { truncateSha } from '~/lib/utils/text_utility'; import { s__, __, sprintf } from '~/locale'; import NoteEditedText from './note_edited_text.vue'; diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index 3bdf8349a12..aabdc1c99b6 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -1,6 +1,7 @@ <script> -import { GlSkeletonLoader, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlSkeletonLoader } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue'; import { getDiffMode } from '~/diffs/store/utils'; diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 930876e90b1..c15c11ed9db 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -19,6 +19,7 @@ export default { editCommentLabel: __('Edit comment'), deleteCommentLabel: __('Delete comment'), moreActionsLabel: __('More actions'), + reportAbuse: __('Report abuse to administrator'), }, name: 'NoteActions', components: { @@ -362,7 +363,7 @@ export default { <!-- eslint-enable @gitlab/vue-no-data-toggle --> <ul class="dropdown-menu more-actions-dropdown dropdown-open-left"> <gl-dropdown-item v-if="canReportAsAbuse" :href="reportAbusePath"> - {{ __('Report abuse to admin') }} + {{ $options.i18n.reportAbuse }} </gl-dropdown-item> <gl-dropdown-item v-if="noteUrl" diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index 82c125b79ce..20cf21cd1b6 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -1,12 +1,10 @@ <script> -import $ from 'jquery'; -import { GlSafeHtmlDirective } from '@gitlab/ui'; import { escape } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; - +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __ } from '~/locale'; -import '~/behaviors/markdown/render_gfm'; import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import autosave from '../mixins/autosave'; import NoteAttachment from './note_attachment.vue'; import NoteAwardsList from './note_awards_list.vue'; @@ -22,7 +20,7 @@ export default { Suggestions, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [autosave], props: { @@ -122,7 +120,7 @@ export default { 'removeSuggestionInfoFromBatch', ]), renderGFM() { - $(this.$refs['note-body']).renderGFM(); + renderGFM(this.$refs['note-body']); }, handleFormUpdate(noteText, parentElement, callback, resolveDiscussion) { this.$emit('handleFormUpdate', { noteText, parentElement, callback, resolveDiscussion }); diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 63c7010983e..36f7d720e48 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -1,17 +1,10 @@ <script> -import { - GlIcon, - GlBadge, - GlLoadingIcon, - GlTooltipDirective, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; +import { GlIcon, GlBadge, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { mapActions } from 'vuex'; import { __, s__ } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { - safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, components: { TimeAgoTooltip, GitlabTeamMemberBadge: () => @@ -21,7 +14,6 @@ export default { GlLoadingIcon, }, directives: { - SafeHtml, GlTooltip: GlTooltipDirective, }, props: { diff --git a/app/assets/javascripts/notes/components/note_signed_out_widget.vue b/app/assets/javascripts/notes/components/note_signed_out_widget.vue index 593933016e1..94636b3e47b 100644 --- a/app/assets/javascripts/notes/components/note_signed_out_widget.vue +++ b/app/assets/javascripts/notes/components/note_signed_out_widget.vue @@ -1,6 +1,6 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { mapGetters } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __, sprintf } from '~/locale'; export default { diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index b668d6ec182..ff801cdccea 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -235,7 +235,7 @@ export default { this.saveNote(replyData) .then((res) => { - if (res.hasFlash !== true) { + if (res.hasAlert !== true) { this.isReplying = false; clearDraft(this.autosaveKey); } @@ -307,7 +307,7 @@ export default { :draft="draftForDiscussion(discussion.reply_id)" :line="line" /> - <div + <li v-else-if="canShowReplyActions && showReplies" :class="{ 'is-replying': isReplying }" class="discussion-reply-holder gl-border-t-0! clearfix" @@ -334,7 +334,7 @@ export default { @cancelForm="cancelReplyForm" /> <note-signed-out-widget v-if="!isLoggedIn" /> - </div> + </li> </template> </discussion-notes> </component> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 8ce0c2f8648..826e7e5a3d0 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -1,16 +1,18 @@ <script> -import { GlSprintf, GlSafeHtmlDirective as SafeHtml, GlAvatarLink, GlAvatar } from '@gitlab/ui'; +import { GlSprintf, GlAvatarLink, GlAvatar } from '@gitlab/ui'; import $ from 'jquery'; import { escape, isEmpty } from 'lodash'; import { mapGetters, mapActions } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants'; import { createAlert } from '~/flash'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_GONE } from '~/lib/utils/http_status'; import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; import { truncateSha } from '~/lib/utils/text_utility'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import { __, s__, sprintf } from '~/locale'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import eventHub from '../event_hub'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; @@ -286,7 +288,7 @@ export default { this.isEditing = false; this.isRequesting = false; this.oldContent = null; - $(this.$refs.noteBody.$el).renderGFM(); + renderGFM(this.$refs.noteBody.$el); this.$refs.noteBody.resetAutoSave(); this.$emit('updateSuccess'); }, @@ -336,7 +338,7 @@ export default { callback(); }) .catch((response) => { - if (response.status === httpStatusCodes.GONE) { + if (response.status === HTTP_STATUS_GONE) { this.removeNote(this.note); this.updateSuccess(); callback(); @@ -515,6 +517,9 @@ export default { @handleFormUpdate="formUpdateHandler" @cancelForm="formCancelHandler" /> + <div class="timeline-discussion-body-footer"> + <slot name="after-note-body"></slot> + </div> </div> </div> </timeline-entry-item> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 7bb1a1a1bfe..fcf37217902 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -1,13 +1,11 @@ <script> import { mapGetters, mapActions } from 'vuex'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; -import { createAlert } from '~/flash'; -import { __ } from '~/locale'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import OrderedLayout from '~/vue_shared/components/ordered_layout.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import DraftNote from '~/batch_comments/components/draft_note.vue'; -import { getLocationHash, doesHashExistInUrl } from '~/lib/utils/url_utility'; +import { getLocationHash } from '~/lib/utils/url_utility'; import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue'; import SkeletonLoadingContainer from '~/vue_shared/components/notes/skeleton_note.vue'; @@ -57,11 +55,6 @@ export default { default: undefined, required: false, }, - userData: { - type: Object, - required: false, - default: () => ({}), - }, shouldShow: { type: Boolean, required: false, @@ -90,16 +83,12 @@ export default { 'commentsDisabled', 'getNoteableData', 'userCanReply', - 'discussionTabCounter', 'sortDirection', 'timelineEnabled', ]), sortDirDesc() { return this.sortDirection === constants.DESC; }, - discussionTabCounterText() { - return this.isLoading ? '' : this.discussionTabCounter; - }, noteableType() { return this.noteableData.noteableType; }, @@ -147,11 +136,6 @@ export default { this.renderSkeleton = !this.shouldShow; }); }, - discussionTabCounterText(val) { - if (this.discussionsCount) { - this.discussionsCount.textContent = val; - } - }, isAppReady: { handler(isReady) { if (!isReady) return; @@ -162,20 +146,7 @@ export default { immediate: true, }, }, - created() { - this.discussionsCount = document.querySelector('.js-discussions-count'); - - this.setNotesData(this.notesData); - this.setNoteableData(this.noteableData); - this.setUserData(this.userData); - this.setTargetNoteHash(getLocationHash()); - eventHub.$once('fetchNotesData', this.fetchNotes); - }, mounted() { - if (this.shouldShow) { - this.fetchNotes(); - } - const { parentElement } = this.$el; if (parentElement && parentElement.classList.contains('js-vue-notes-event')) { parentElement.addEventListener('toggleAward', (event) => { @@ -200,23 +171,16 @@ export default { }, methods: { ...mapActions([ - 'setFetchingState', - 'setLoadingState', - 'fetchDiscussions', - 'poll', 'toggleAward', - 'setNotesData', - 'setNoteableData', - 'setUserData', 'setLastFetchedAt', 'setTargetNoteHash', 'toggleDiscussion', - 'setNotesFetchedState', 'expandDiscussion', 'startTaskList', 'convertToDiscussion', 'stopPolling', 'setConfidentiality', + 'fetchNotes', ]), discussionIsIndividualNoteAndNotConverted(discussion) { return discussion.individual_note && !this.convertedDisscussionIds.includes(discussion.id); @@ -228,37 +192,6 @@ export default { this.setTargetNoteHash(getLocationHash()); } }, - fetchNotes() { - if (this.isFetching) return null; - - this.setFetchingState(true); - - return this.fetchDiscussions(this.getFetchDiscussionsConfig()) - .then(this.initPolling) - .then(() => { - this.setLoadingState(false); - this.setNotesFetchedState(true); - eventHub.$emit('fetchedNotesData'); - this.setFetchingState(false); - }) - .catch(() => { - this.setLoadingState(false); - this.setNotesFetchedState(true); - createAlert({ - message: __('Something went wrong while fetching comments. Please try again.'), - }); - }); - }, - initPolling() { - if (this.isPollingInitialized) { - return; - } - - this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt')); - - this.poll(); - this.isPollingInitialized = true; - }, checkLocationHash() { const hash = getLocationHash(); const noteId = hash && hash.replace(/^note_/, ''); @@ -278,24 +211,6 @@ export default { .then(this.$nextTick) .then(() => eventHub.$emit('startReplying', discussionId)); }, - getFetchDiscussionsConfig() { - const defaultConfig = { path: this.getNotesDataByProp('discussionsPath') }; - - const currentFilter = - this.getNotesDataByProp('notesFilter') || constants.DISCUSSION_FILTERS_DEFAULT_VALUE; - - if ( - doesHashExistInUrl(constants.NOTE_UNDERSCORE) && - currentFilter !== constants.DISCUSSION_FILTERS_DEFAULT_VALUE - ) { - return { - ...defaultConfig, - filter: constants.DISCUSSION_FILTERS_DEFAULT_VALUE, - persistFilter: false, - }; - } - return defaultConfig; - }, }, systemNote: constants.SYSTEM_NOTE, }; diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index defcb0533b7..95263e666b2 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; +import { getLocationHash } from '~/lib/utils/url_utility'; import NotesApp from './components/notes_app.vue'; import { store } from './stores'; import { getNotesFilterData } from './utils/get_notes_filter_data'; @@ -13,6 +14,34 @@ export default () => { const notesFilterProps = getNotesFilterData(el); const showTimelineViewToggle = parseBoolean(el.dataset.showTimelineViewToggle); + const notesDataset = el.dataset; + const parsedUserData = JSON.parse(notesDataset.currentUserData); + const noteableData = JSON.parse(notesDataset.noteableData); + let currentUserData = {}; + + noteableData.noteableType = notesDataset.noteableType; + noteableData.targetType = notesDataset.targetType; + noteableData.discussion_locked = parseBoolean(noteableData.discussion_locked); + + if (parsedUserData) { + currentUserData = { + id: parsedUserData.id, + name: parsedUserData.name, + username: parsedUserData.username, + avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url, + path: parsedUserData.path, + can_add_timeline_events: parseBoolean(notesDataset.canAddTimelineEvents), + }; + } + + const notesData = JSON.parse(notesDataset.notesData); + + store.dispatch('setNotesData', notesData); + store.dispatch('setNoteableData', noteableData); + store.dispatch('setUserData', currentUserData); + store.dispatch('setTargetNoteHash', getLocationHash()); + store.dispatch('fetchNotes'); + // eslint-disable-next-line no-new new Vue({ el, @@ -25,30 +54,6 @@ export default () => { showTimelineViewToggle, }, data() { - const notesDataset = el.dataset; - const parsedUserData = JSON.parse(notesDataset.currentUserData); - const noteableData = JSON.parse(notesDataset.noteableData); - let currentUserData = {}; - - noteableData.noteableType = notesDataset.noteableType; - noteableData.targetType = notesDataset.targetType; - if (noteableData.discussion_locked === null) { - // discussion_locked has never been set for this issuable. - // set to `false` for safety. - noteableData.discussion_locked = false; - } - - if (parsedUserData) { - currentUserData = { - id: parsedUserData.id, - name: parsedUserData.name, - username: parsedUserData.username, - avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url, - path: parsedUserData.path, - can_add_timeline_events: parseBoolean(notesDataset.canAddTimelineEvents), - }; - } - return { noteableData, currentUserData, diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index fcef26d720c..d290a8ccb84 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -2,14 +2,14 @@ import $ from 'jquery'; import Visibility from 'visibilityjs'; import Vue from 'vue'; import Api from '~/api'; -import createFlash from '~/flash'; +import { createAlert, VARIANT_INFO } from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; import toast from '~/vue_shared/plugins/global_toast'; import { confidentialWidget } from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; -import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql'; -import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql'; +import updateIssueLockMutation from '~/sidebar/queries/update_issue_lock.mutation.graphql'; +import updateMergeRequestLockMutation from '~/sidebar/queries/update_merge_request_lock.mutation.graphql'; import loadAwardsHandler from '~/awards_handler'; import { isInViewport, scrollToElement, isInMRPage } from '~/lib/utils/common_utils'; import Poll from '~/lib/utils/poll'; @@ -114,6 +114,39 @@ export const fetchDiscussions = ( }); }; +export const fetchNotes = ({ dispatch, getters }) => { + if (getters.isFetching) return null; + + dispatch('setFetchingState', true); + + return dispatch('fetchDiscussions', getters.getFetchDiscussionsConfig) + .then(() => dispatch('initPolling')) + .then(() => { + dispatch('setLoadingState', false); + dispatch('setNotesFetchedState', true); + notesEventHub.$emit('fetchedNotesData'); + dispatch('setFetchingState', false); + }) + .catch(() => { + dispatch('setLoadingState', false); + dispatch('setNotesFetchedState', true); + createAlert({ + message: __('Something went wrong while fetching comments. Please try again.'), + }); + }); +}; + +export const initPolling = ({ state, dispatch, getters, commit }) => { + if (state.isPollingInitialized) { + return; + } + + dispatch('setLastFetchedAt', getters.getNotesDataByProp('lastFetchedAt')); + + dispatch('poll'); + commit(types.SET_IS_POLLING_INITIALIZED, true); +}; + export const fetchDiscussionsBatch = ({ commit, dispatch }, { path, config, cursor, perPage }) => { const params = { ...config?.params, per_page: perPage }; @@ -270,7 +303,7 @@ export const promoteCommentToTimelineEvent = ( errorObj = error; } - createFlash({ + createAlert({ message, captureError, error: errorObj, @@ -465,9 +498,9 @@ export const saveNote = ({ commit, dispatch }, noteData) => { $('.js-gfm-input').trigger('clear-commands-cache.atwho'); - createFlash({ + createAlert({ message: message || __('Commands applied'), - type: 'notice', + variant: VARIANT_INFO, parent: noteData.flashContainer, }); } @@ -490,7 +523,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => { awardsHandler.scrollToAwards(); }) .catch(() => { - createFlash({ + createAlert({ message: __('Something went wrong while adding your award. Please try again.'), parent: noteData.flashContainer, }); @@ -529,11 +562,11 @@ export const saveNote = ({ commit, dispatch }, noteData) => { const errorMsg = sprintf(__('Your comment could not be submitted because %{error}'), { error: base[0].toLowerCase(), }); - createFlash({ + createAlert({ message: errorMsg, parent: noteData.flashContainer, }); - return { ...data, hasFlash: true }; + return { ...data, hasAlert: true }; } } @@ -580,7 +613,7 @@ const getFetchDataParams = (state) => { export const poll = ({ commit, state, getters, dispatch }) => { const notePollOccurrenceTracking = create(); - let flashContainer; + let alert; notePollOccurrenceTracking.handle(1, () => { // Since polling halts internally after 1 failure, we manually try one more time @@ -588,7 +621,7 @@ export const poll = ({ commit, state, getters, dispatch }) => { }); notePollOccurrenceTracking.handle(2, () => { // On the second failure in a row, show the alert and try one more time (hoping to succeed and clear the error) - flashContainer = createFlash({ + alert = createAlert({ message: __('Something went wrong while fetching latest comments.'), }); setTimeout(() => eTagPoll.restart(), NOTES_POLLING_INTERVAL); @@ -608,7 +641,7 @@ export const poll = ({ commit, state, getters, dispatch }) => { if (notePollOccurrenceTracking.count) { notePollOccurrenceTracking.reset(); } - flashContainer?.close(); + alert?.dismiss(); }, errorCallback: () => notePollOccurrenceTracking.occur(), }); @@ -681,7 +714,7 @@ export const filterDiscussion = ({ commit, dispatch }, { path, filter, persistFi .catch(() => { dispatch('setLoadingState', false); dispatch('setNotesFetchedState', true); - createFlash({ + createAlert({ message: __('Something went wrong while fetching comments. Please try again.'), }); }); @@ -726,7 +759,7 @@ export const submitSuggestion = ( const flashMessage = errorMessage || defaultMessage; - createFlash({ + createAlert({ message: flashMessage, parent: flashContainer, }); @@ -762,7 +795,7 @@ export const submitSuggestionBatch = ({ commit, dispatch, state }, { message, fl const flashMessage = errorMessage || defaultMessage; - createFlash({ + createAlert({ message: flashMessage, parent: flashContainer, }); @@ -804,7 +837,7 @@ export const fetchDescriptionVersion = ({ dispatch }, { endpoint, startingVersio }) .catch((error) => { dispatch('receiveDescriptionVersionError', error); - createFlash({ + createAlert({ message: __('Something went wrong while fetching description changes. Please try again.'), }); }); @@ -838,7 +871,7 @@ export const softDeleteDescriptionVersion = ( }) .catch((error) => { dispatch('receiveDeleteDescriptionVersionError', error); - createFlash({ + createAlert({ message: __('Something went wrong while deleting description changes. Please try again.'), }); diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 5ad7a811726..f6373f24b74 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -2,6 +2,7 @@ import { flattenDeep, clone } from 'lodash'; import { match } from '~/diffs/utils/diff_file'; import { badgeState } from '~/issuable/components/status_box.vue'; import { isInMRPage } from '~/lib/utils/common_utils'; +import { doesHashExistInUrl } from '~/lib/utils/url_utility'; import * as constants from '../constants'; import { collapseSystemNotes } from './collapse_utils'; @@ -314,3 +315,22 @@ export const getSuggestionsFilePaths = (state) => () => return acc; }, []); + +export const getFetchDiscussionsConfig = (state, getters) => { + const defaultConfig = { path: getters.getNotesDataByProp('discussionsPath') }; + + const currentFilter = + getters.getNotesDataByProp('notesFilter') || constants.DISCUSSION_FILTERS_DEFAULT_VALUE; + + if ( + doesHashExistInUrl(constants.NOTE_UNDERSCORE) && + currentFilter !== constants.DISCUSSION_FILTERS_DEFAULT_VALUE + ) { + return { + ...defaultConfig, + filter: constants.DISCUSSION_FILTERS_DEFAULT_VALUE, + persistFilter: false, + }; + } + return defaultConfig; +}; diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index 7ba1f470b05..81c4c42a49a 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -50,6 +50,7 @@ export default () => ({ descriptionVersions: {}, isTimelineEnabled: false, isFetching: false, + isPollingInitialized: false, }, actions, getters, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 42df6bc0980..bc1d5b5bba4 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -27,6 +27,7 @@ export const CLEAR_SUGGESTION_BATCH = 'CLEAR_SUGGESTION_BATCH'; export const CONVERT_TO_DISCUSSION = 'CONVERT_TO_DISCUSSION'; export const REMOVE_CONVERTED_DISCUSSION = 'REMOVE_CONVERTED_DISCUSSION'; export const UPDATE_ASSIGNEES = 'UPDATE_ASSIGNEES'; +export const SET_IS_POLLING_INITIALIZED = 'SET_IS_POLLING_INITIALIZED'; // DISCUSSION export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 83c15c12eac..5d532b68f1b 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -428,4 +428,7 @@ export default { [types.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS](state, value) { state.isPromoteCommentToTimelineEventInProgress = value; }, + [types.SET_IS_POLLING_INITIALIZED](state, value) { + state.isPollingInitialized = value; + }, }; diff --git a/app/assets/javascripts/observability/components/observability_app.vue b/app/assets/javascripts/observability/components/observability_app.vue index 4f5e27be46f..33d23ea043b 100644 --- a/app/assets/javascripts/observability/components/observability_app.vue +++ b/app/assets/javascripts/observability/components/observability_app.vue @@ -1,21 +1,69 @@ <script> +import { darkModeEnabled } from '~/lib/utils/color_utils'; +import { setUrlParams } from '~/lib/utils/url_utility'; + +import { MESSAGE_EVENT_TYPE, OBSERVABILITY_ROUTES, SKELETON_VARIANT } from '../constants'; +import ObservabilitySkeleton from './skeleton/index.vue'; + export default { + components: { + ObservabilitySkeleton, + }, props: { observabilityIframeSrc: { type: String, required: true, }, }, + computed: { + iframeSrcWithParams() { + return setUrlParams( + { theme: darkModeEnabled() ? 'dark' : 'light', username: gon?.current_username }, + this.observabilityIframeSrc, + ); + }, + getSkeletonVariant() { + switch (this.$route.path) { + case OBSERVABILITY_ROUTES.DASHBOARDS: + return SKELETON_VARIANT.DASHBOARDS; + case OBSERVABILITY_ROUTES.EXPLORE: + return SKELETON_VARIANT.EXPLORE; + case OBSERVABILITY_ROUTES.MANAGE: + return SKELETON_VARIANT.MANAGE; + default: + return SKELETON_VARIANT.DASHBOARDS; + } + }, + }, mounted() { window.addEventListener('message', this.messageHandler); }, + destroyed() { + window.removeEventListener('message', this.messageHandler); + }, methods: { messageHandler(e) { const isExpectedOrigin = e.origin === new URL(this.observabilityIframeSrc)?.origin; + if (!isExpectedOrigin) return; - const isNewObservabilityPath = this.$route?.query?.observability_path !== e.data?.url; + const { + data: { type, payload }, + } = e; + switch (type) { + case MESSAGE_EVENT_TYPE.GOUI_LOADED: + this.$refs.iframeSkeleton.handleSkeleton(); + break; + case MESSAGE_EVENT_TYPE.GOUI_ROUTE_UPDATE: + this.routeUpdateHandler(payload); + break; + default: + break; + } + }, + routeUpdateHandler(payload) { + const isNewObservabilityPath = this.$route?.query?.observability_path !== payload?.url; - const shouldNotHandleMessage = !isExpectedOrigin || !e.data.url || !isNewObservabilityPath; + const shouldNotHandleMessage = !payload.url || !isNewObservabilityPath; if (shouldNotHandleMessage) { return; @@ -24,7 +72,7 @@ export default { // this will update the `observability_path` query param on each route change inside Observability UI this.$router.replace({ name: this.$route.pathname, - query: { ...this.$route.query, observability_path: e.data.url }, + query: { ...this.$route.query, observability_path: payload.url }, }); }, }, @@ -32,11 +80,14 @@ export default { </script> <template> - <iframe - id="observability-ui-iframe" - data-testid="observability-ui-iframe" - frameborder="0" - height="100%" - :src="observabilityIframeSrc" - ></iframe> + <observability-skeleton ref="iframeSkeleton" :variant="getSkeletonVariant"> + <iframe + id="observability-ui-iframe" + data-testid="observability-ui-iframe" + frameborder="0" + height="100%" + :src="iframeSrcWithParams" + sandbox="allow-same-origin allow-forms allow-scripts" + ></iframe> + </observability-skeleton> </template> diff --git a/app/assets/javascripts/observability/components/skeleton/dashboards.vue b/app/assets/javascripts/observability/components/skeleton/dashboards.vue new file mode 100644 index 00000000000..8b106407953 --- /dev/null +++ b/app/assets/javascripts/observability/components/skeleton/dashboards.vue @@ -0,0 +1,29 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +export default { + components: { + GlSkeletonLoader, + }, +}; +</script> +<template> + <gl-skeleton-loader :height="200"> + <!-- Top left --> + <rect y="2" width="10" height="8" /> + <rect y="2" x="15" width="15" height="8" /> + <rect y="2" x="35" width="15" height="8" /> + + <!-- Top right --> + <rect y="2" x="354" width="10" height="8" /> + <rect y="2" x="366" width="10" height="8" /> + <rect y="2" x="378" width="10" height="8" /> + <rect y="2" x="390" width="10" height="8" /> + + <!-- Middle header --> + <rect y="15" width="400" height="30" rx="2" ry="2" /> + + <!-- Dashboard container --> + <rect y="50" width="200" height="100" rx="2" ry="2" /> + </gl-skeleton-loader> +</template> diff --git a/app/assets/javascripts/observability/components/skeleton/explore.vue b/app/assets/javascripts/observability/components/skeleton/explore.vue new file mode 100644 index 00000000000..1fcbd4fb1cb --- /dev/null +++ b/app/assets/javascripts/observability/components/skeleton/explore.vue @@ -0,0 +1,27 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +export default { + components: { + GlSkeletonLoader, + }, +}; +</script> +<template> + <gl-skeleton-loader :height="200"> + <!-- Top left --> + <circle y="2" cx="6" cy="6" r="4" /> + <rect y="2" x="15" width="15" height="8" /> + <rect y="2" x="35" width="40" height="8" /> + + <!-- Top right --> + + <rect y="2" x="263" width="13" height="8" /> + <rect y="2" x="278" width="8" height="8" /> + <rect y="2" x="288" width="50" height="8" /> + <rect y="2" x="340" width="18" height="8" /> + <rect y="2" x="360" width="30" height="8" /> + + <rect y="15" width="400" height="30" rx="2" ry="2" /> + </gl-skeleton-loader> +</template> diff --git a/app/assets/javascripts/observability/components/skeleton/index.vue b/app/assets/javascripts/observability/components/skeleton/index.vue new file mode 100644 index 00000000000..1e2671c8166 --- /dev/null +++ b/app/assets/javascripts/observability/components/skeleton/index.vue @@ -0,0 +1,89 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; +import { SKELETON_VARIANT } from '../../constants'; +import DashboardsSkeleton from './dashboards.vue'; +import ExploreSkeleton from './explore.vue'; +import ManageSkeleton from './manage.vue'; + +export default { + SKELETON_VARIANT, + components: { + GlSkeletonLoader, + DashboardsSkeleton, + ExploreSkeleton, + ManageSkeleton, + }, + props: { + variant: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + loading: null, + timerId: null, + }; + }, + mounted() { + this.timerId = setTimeout(() => { + /** + * If observability UI is not loaded then this.loading would be null + * we will show skeleton in that case + */ + if (this.loading !== false) { + this.showSkeleton(); + } + }, 500); + }, + methods: { + handleSkeleton() { + if (this.loading === null) { + /** + * If observability UI content loads with in 500ms + * do not show skeleton. + */ + clearTimeout(this.timerId); + return; + } + + /** + * If observability UI content loads after 500ms + * wait for 400ms to hide skeleton. + * This is mostly to avoid the flashing effect If content loads imediately after skeleton + */ + setTimeout(this.hideSkeleton, 400); + }, + hideSkeleton() { + this.loading = false; + }, + showSkeleton() { + this.loading = true; + }, + }, +}; +</script> +<template> + <div class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch"> + <div v-show="loading" class="gl-px-5"> + <dashboards-skeleton v-if="variant === $options.SKELETON_VARIANT.DASHBOARDS" /> + <explore-skeleton v-else-if="variant === $options.SKELETON_VARIANT.EXPLORE" /> + <manage-skeleton v-else-if="variant === $options.SKELETON_VARIANT.MANAGE" /> + + <gl-skeleton-loader v-else> + <rect y="2" width="10" height="8" /> + <rect y="2" x="15" width="15" height="8" /> + <rect y="2" x="35" width="15" height="8" /> + <rect y="15" width="400" height="30" /> + </gl-skeleton-loader> + </div> + + <div + v-show="!loading" + class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch" + > + <slot></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/observability/components/skeleton/manage.vue b/app/assets/javascripts/observability/components/skeleton/manage.vue new file mode 100644 index 00000000000..4b029120328 --- /dev/null +++ b/app/assets/javascripts/observability/components/skeleton/manage.vue @@ -0,0 +1,25 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +export default { + components: { + GlSkeletonLoader, + }, +}; +</script> +<template> + <gl-skeleton-loader :height="200"> + <!-- Top header--> + <rect y="2" width="400" height="30" /> + + <rect y="35" x="65" width="80" height="8" /> + <rect y="35" x="205" width="30" height="8" /> + <rect y="35" x="240" width="25" height="8" /> + <rect y="35" x="270" width="20" height="8" /> + + <rect y="55" x="65" width="100" height="8" /> + <rect y="55" x="225" width="65" height="8" /> + + <rect y="65" x="65" width="225" height="200" rx="2" ry="2" /> + </gl-skeleton-loader> +</template> diff --git a/app/assets/javascripts/observability/constants.js b/app/assets/javascripts/observability/constants.js new file mode 100644 index 00000000000..74dd543e285 --- /dev/null +++ b/app/assets/javascripts/observability/constants.js @@ -0,0 +1,16 @@ +export const MESSAGE_EVENT_TYPE = Object.freeze({ + GOUI_LOADED: 'GOUI_LOADED', + GOUI_ROUTE_UPDATE: 'GOUI_ROUTE_UPDATE', +}); + +export const OBSERVABILITY_ROUTES = Object.freeze({ + DASHBOARDS: '/groups/gitlab-org/-/observability/dashboards', + EXPLORE: '/groups/gitlab-org/-/observability/explore', + MANAGE: '/groups/gitlab-org/-/observability/manage', +}); + +export const SKELETON_VARIANT = Object.freeze({ + DASHBOARDS: 'dashboards', + EXPLORE: 'explore', + MANAGE: 'manage', +}); diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue index 1b7d5af6134..56d2ff86fb7 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue @@ -1,11 +1,7 @@ <script> import { GlSprintf, GlAlert, GlLink } from '@gitlab/ui'; -import { - ALERT_MESSAGES, - ADMIN_GARBAGE_COLLECTION_TIP, - ALERT_DANGER_IMPORTING, -} from '../../constants/index'; +import { ALERT_MESSAGES, ADMIN_GARBAGE_COLLECTION_TIP } from '../../constants/index'; export default { components: { @@ -27,7 +23,6 @@ export default { }, }, garbageCollectionHelpPagePath: { type: String, required: false, default: '' }, - containerRegistryImportingHelpPagePath: { type: String, required: false, default: '' }, isAdmin: { type: Boolean, default: false, @@ -53,11 +48,6 @@ export default { } return config; }, - alertHref() { - return this.deleteAlertType === ALERT_DANGER_IMPORTING - ? this.containerRegistryImportingHelpPagePath - : this.garbageCollectionHelpPagePath; - }, }, }; </script> @@ -71,7 +61,7 @@ export default { > <gl-sprintf :message="deleteAlertConfig.message"> <template #docLink="{ content }"> - <gl-link :href="alertHref" target="_blank"> + <gl-link :href="garbageCollectionHelpPagePath" target="_blank"> {{ content }} </gl-link> </template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue index 597df2b9bc3..c10d8be69a0 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue @@ -6,8 +6,8 @@ import { joinPaths } from '~/lib/utils/url_utility'; import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; -import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import { REMOVE_TAGS_BUTTON_TITLE, TAGS_LIST_TITLE, diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js index 98c24350f09..7bb69363743 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js @@ -93,10 +93,6 @@ export const DETAILS_DELETE_IMAGE_ERROR_MESSAGE = s__( 'ContainerRegistry|Something went wrong while scheduling the image for deletion.', ); -export const DETAILS_IMPORTING_ERROR_MESSAGE = s__( - 'ContainerRegistry|Tags temporarily cannot be marked for deletion. Please try again in a few minutes. %{docLinkStart}More details%{docLinkEnd}.', -); - export const DELETE_IMAGE_CONFIRMATION_TITLE = s__('ContainerRegistry|Delete image repository?'); export const DELETE_IMAGE_CONFIRMATION_TEXT = s__( 'ContainerRegistry|Deleting the image repository will delete all images and tags inside. This action cannot be undone. Please type the following to confirm: %{code}', @@ -137,7 +133,6 @@ export const ALERT_DANGER_TAG = 'danger_tag'; export const ALERT_SUCCESS_TAGS = 'success_tags'; export const ALERT_DANGER_TAGS = 'danger_tags'; export const ALERT_DANGER_IMAGE = 'danger_image'; -export const ALERT_DANGER_IMPORTING = 'danger_importing'; export const DELETE_SCHEDULED = 'DELETE_SCHEDULED'; export const DELETE_FAILED = 'DELETE_FAILED'; @@ -148,7 +143,6 @@ export const ALERT_MESSAGES = { [ALERT_SUCCESS_TAGS]: DELETE_TAGS_SUCCESS_MESSAGE, [ALERT_DANGER_TAGS]: DELETE_TAGS_ERROR_MESSAGE, [ALERT_DANGER_IMAGE]: DETAILS_DELETE_IMAGE_ERROR_MESSAGE, - [ALERT_DANGER_IMPORTING]: DETAILS_IMPORTING_ERROR_MESSAGE, }; export const UNFINISHED_STATUS = 'UNFINISHED'; diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue index b339c8c8371..83c0d2cdfca 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue @@ -20,7 +20,6 @@ import { ALERT_SUCCESS_TAGS, ALERT_DANGER_TAGS, ALERT_DANGER_IMAGE, - ALERT_DANGER_IMPORTING, FETCH_IMAGES_LIST_ERROR_MESSAGE, UNFINISHED_STATUS, MISSING_OR_DELETED_IMAGE_BREADCRUMB, @@ -33,8 +32,6 @@ import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container import getContainerRepositoryTagsQuery from '../graphql/queries/get_container_repository_tags.query.graphql'; import getContainerRepositoriesDetails from '../graphql/queries/get_container_repositories_details.query.graphql'; -const REPOSITORY_IMPORTING_ERROR_MESSAGE = 'repository importing'; - export default { name: 'RegistryDetailsPage', components: { @@ -157,17 +154,12 @@ export default { }); if (data?.destroyContainerRepositoryTags?.errors[0]) { - throw new Error(data.destroyContainerRepositoryTags.errors[0]); + throw new Error(); } this.deleteAlertType = itemsToBeDeleted.length === 0 ? ALERT_SUCCESS_TAG : ALERT_SUCCESS_TAGS; } catch (e) { - if (e.message === REPOSITORY_IMPORTING_ERROR_MESSAGE) { - this.deleteAlertType = ALERT_DANGER_IMPORTING; - } else { - this.deleteAlertType = - itemsToBeDeleted.length === 0 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS; - } + this.deleteAlertType = itemsToBeDeleted.length === 0 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS; } this.mutationLoading = false; @@ -203,7 +195,6 @@ export default { <delete-alert v-model="deleteAlertType" :garbage-collection-help-page-path="config.garbageCollectionHelpPagePath" - :container-registry-importing-help-page-path="config.containerRegistryImportingHelpPagePath" :is-admin="config.isAdmin" class="gl-my-2" /> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue index 794be8d5195..8a038d7c974 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue @@ -11,9 +11,9 @@ import { import { get } from 'lodash'; import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; import { createAlert } from '~/flash'; -import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import Tracking from '~/tracking'; import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import DeleteImage from '../components/delete_image.vue'; import RegistryHeader from '../components/list_page/registry_header.vue'; diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue index c6ab746b9f4..bafcd78ad5d 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue @@ -8,7 +8,7 @@ import { TOKEN_TYPE_TAG_NAME, TAG_LABEL, } from '~/packages_and_registries/harbor_registry/constants/index'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; import { createAlert } from '~/flash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue'; @@ -39,7 +39,7 @@ export default { title: TAG_LABEL, unique: true, token: GlFilteredSearchToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }, ], data() { diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js b/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js index 13df303cffe..2ae5957343b 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js @@ -3,8 +3,8 @@ import { SORT_FIELD_MAPPING, TOKEN_TYPE_TAG_NAME, } from '~/packages_and_registries/harbor_registry/constants'; -import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; export const extractSortingDetail = (parsedSorting = '') => { const [orderBy, sortOrder] = parsedSorting.split('_'); diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue index 2adf6187c4b..0aeeb2c3d15 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue @@ -4,16 +4,14 @@ import { mapActions, mapState } from 'vuex'; import { createAlert, VARIANT_INFO } from '~/flash'; import { historyReplaceState } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; -import { - SHOW_DELETE_SUCCESS_ALERT, - FILTERED_SEARCH_TERM, -} from '~/packages_and_registries/shared/constants'; +import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages_and_registries/shared/constants'; import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils'; import InfrastructureTitle from '~/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue'; import InfrastructureSearch from '~/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue'; import PackageList from '~/packages_and_registries/infrastructure_registry/list/components/packages_list.vue'; import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages_and_registries/infrastructure_registry/list/constants'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; export default { components: { diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js index 37b51797490..7a452abdc26 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js @@ -2,6 +2,7 @@ import Api from '~/api'; import { createAlert, VARIANT_SUCCESS } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages_and_registries/shared/constants'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import { FETCH_PACKAGES_LIST_ERROR_MESSAGE, DELETE_PACKAGE_SUCCESS_MESSAGE, @@ -31,7 +32,7 @@ export const requestPackagesList = ({ dispatch, state }, params = {}) => { const type = state.config.forceTerraform ? TERRAFORM_SEARCH_TYPE : state.filter.find((f) => f.type === 'type'); - const name = state.filter.find((f) => f.type === 'filtered-search-term'); + const name = state.filter.find((f) => f.type === FILTERED_SEARCH_TERM); const packageFilters = { package_type: type?.value?.data, package_name: name?.value?.data }; const apiMethod = state.config.isGroupPage ? 'groupPackages' : 'projectPackages'; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue index 4553dd3421b..7ad1ebac11e 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue @@ -54,6 +54,9 @@ export default { }, }, computed: { + containsWebPathLink() { + return Boolean(this.packageEntity?._links?.webPath); + }, packageType() { return getPackageTypeLabel(this.packageEntity.packageType); }, @@ -109,6 +112,7 @@ export default { <template #left-primary> <div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0"> <router-link + v-if="containsWebPathLink" :class="errorPackageStyle" class="gl-text-body gl-min-w-0" data-testid="details-link" @@ -118,6 +122,7 @@ export default { > <gl-truncate :text="packageEntity.name" /> </router-link> + <gl-truncate v-else :text="packageEntity.name" /> <package-tags v-if="showTags" diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue index d28847c7900..0cf49b25bf2 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue @@ -1,14 +1,14 @@ <script> -import { s__ } from '~/locale'; import { sortableFields } from '~/packages_and_registries/package_registry/utils'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { + FILTERED_SEARCH_TERM, + OPERATORS_IS, + TOKEN_TITLE_TYPE, + TOKEN_TYPE_TYPE, +} from '~/vue_shared/components/filtered_search_bar/constants'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue'; import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils'; -import { - FILTERED_SEARCH_TERM, - FILTERED_SEARCH_TYPE, -} from '~/packages_and_registries/shared/constants'; import { LIST_KEY_CREATED_AT } from '~/packages_and_registries/package_registry/constants'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import PackageTypeToken from './tokens/package_type_token.vue'; @@ -16,12 +16,12 @@ import PackageTypeToken from './tokens/package_type_token.vue'; export default { tokens: [ { - type: 'type', + type: TOKEN_TYPE_TYPE, icon: 'package', - title: s__('PackageRegistry|Type'), + title: TOKEN_TITLE_TYPE, unique: true, token: PackageTypeToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }, ], components: { RegistrySearch, UrlSync, LocalStorageSync }, @@ -51,7 +51,7 @@ export default { }; return this.filters.reduce((acc, filter) => { - if (filter.type === FILTERED_SEARCH_TYPE && filter.value?.data) { + if (filter.type === TOKEN_TYPE_TYPE && filter.value?.data) { return { ...acc, packageType: filter.value.data.toUpperCase(), diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql index b5695a01376..2d405f3e9cc 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql @@ -29,4 +29,7 @@ fragment PackageData on Package { fullPath webUrl } + _links { + webPath + } } diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql index 51e0ab5aba8..9153906a38c 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql @@ -62,6 +62,7 @@ query getPackageDetails( } } versions(after: $after, before: $before, first: $first, last: $last) { + count nodes { id name diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue index c59dcaee411..03352f01aca 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue @@ -304,7 +304,7 @@ export default { deleteFileModalContent: s__( `PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?`, ), - otherVersionsTabTitle: __('Other versions'), + otherVersionsTabTitle: s__('PackageRegistry|Other versions'), }, modal: { packageDeletePrimaryAction: { @@ -380,7 +380,9 @@ export default { <gl-tab v-if="showDependencies"> <template #title> <span>{{ __('Dependencies') }}</span> - <gl-badge size="sm">{{ packageDependencies.length }}</gl-badge> + <gl-badge size="sm" data-testid="dependencies-badge">{{ + packageDependencies.length + }}</gl-badge> </template> <template v-if="packageDependencies.length > 0"> @@ -392,7 +394,14 @@ export default { </p> </gl-tab> - <gl-tab :title="$options.i18n.otherVersionsTabTitle" title-item-class="js-versions-tab" lazy> + <gl-tab title-item-class="js-versions-tab" lazy> + <template #title> + <span>{{ $options.i18n.otherVersionsTabTitle }}</span> + <gl-badge size="sm" class="gl-tab-counter-badge" data-testid="other-versions-badge">{{ + packageEntity.versions.count + }}</gl-badge> + </template> + <package-versions-list :is-loading="isLoading" :page-info="versionPageInfo" diff --git a/app/assets/javascripts/packages_and_registries/shared/components/package_path.vue b/app/assets/javascripts/packages_and_registries/shared/components/package_path.vue index 6fb001e5e92..0a94f67ea5e 100644 --- a/app/assets/javascripts/packages_and_registries/shared/components/package_path.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/package_path.vue @@ -47,7 +47,7 @@ export default { </script> <template> - <div data-qa-selector="package-path" class="gl-display-flex gl-align-items-center"> + <div data-qa-selector="package_path" class="gl-display-flex gl-align-items-center"> <gl-icon data-testid="base-icon" name="project" class="gl-mx-3 gl-min-w-0" /> <gl-link diff --git a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js index f3ce967b756..fe6e06ad830 100644 --- a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js +++ b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js @@ -1,7 +1,5 @@ import { s__ } from '~/locale'; -export const FILTERED_SEARCH_TERM = 'filtered-search-term'; -export const FILTERED_SEARCH_TYPE = 'type'; export const HISTORY_PIPELINES_LIMIT = 5; export const DELETE_PACKAGE_TRACKING_ACTION = 'delete_package'; diff --git a/app/assets/javascripts/packages_and_registries/shared/utils.js b/app/assets/javascripts/packages_and_registries/shared/utils.js index 7e963cd0b08..76623377d90 100644 --- a/app/assets/javascripts/packages_and_registries/shared/utils.js +++ b/app/assets/javascripts/packages_and_registries/shared/utils.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import { queryToObject } from '~/lib/utils/url_utility'; -import { FILTERED_SEARCH_TERM } from './constants'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; export const getQueryParams = (query) => queryToObject(query, { gatherArrays: true, legacySpacesDecode: true }); diff --git a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue index b68148e5461..96477b9f476 100644 --- a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue +++ b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue @@ -43,7 +43,6 @@ export default { 'settingsPath', 'signupEnabled', 'requireAdminApprovalAfterUserSignup', - 'sendUserConfirmationEmail', 'emailConfirmationSetting', 'minimumPasswordLength', 'minimumPasswordLengthMin', @@ -68,7 +67,6 @@ export default { form: { signupEnabled: this.signupEnabled, requireAdminApproval: this.requireAdminApprovalAfterUserSignup, - sendConfirmationEmail: this.sendUserConfirmationEmail, emailConfirmationSetting: this.emailConfirmationSetting, minimumPasswordLength: this.minimumPasswordLength, minimumPasswordLengthMin: this.minimumPasswordLengthMin, @@ -204,7 +202,6 @@ export default { buttonText: s__('ApplicationSettings|Save changes'), signupEnabledLabel: s__('ApplicationSettings|Sign-up enabled'), requireAdminApprovalLabel: s__('ApplicationSettings|Require admin approval for new sign-ups'), - sendConfirmationEmailLabel: s__('ApplicationSettings|Send confirmation email on sign-up'), emailConfirmationSettingsLabel: s__('ApplicationSettings|Email confirmation settings'), emailConfirmationSettingsOffLabel: s__('ApplicationSettings|Off'), emailConfirmationSettingsOffHelpText: s__( @@ -284,13 +281,6 @@ export default { data-testid="require-admin-approval-checkbox" /> - <signup-checkbox - v-model="form.sendConfirmationEmail" - class="gl-mb-5" - name="application_setting[send_user_confirmation_email]" - :label="$options.i18n.sendConfirmationEmailLabel" - /> - <gl-form-group :label="$options.i18n.emailConfirmationSettingsLabel"> <gl-form-radio-group v-model="form.emailConfirmationSetting" diff --git a/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js b/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js index 0d5c55cb87b..395d8a38bf7 100644 --- a/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js +++ b/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js @@ -14,7 +14,6 @@ export default function initSignupRestrictions(elementSelector = '#js-signup-for booleanAttributes: [ 'signupEnabled', 'requireAdminApprovalAfterUserSignup', - 'sendUserConfirmationEmail', 'domainDenylistEnabled', 'denylistTypeRawSelected', 'emailRestrictionsEnabled', diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/edit/index.js b/app/assets/javascripts/pages/admin/broadcast_messages/edit/index.js new file mode 100644 index 00000000000..25036984082 --- /dev/null +++ b/app/assets/javascripts/pages/admin/broadcast_messages/edit/index.js @@ -0,0 +1,8 @@ +import initEditBroadcastMessage from '~/admin/broadcast_messages/edit'; +import initBroadcastMessagesForm from '../broadcast_message'; + +if (gon.features.vueBroadcastMessages) { + initEditBroadcastMessage(); +} else { + initBroadcastMessagesForm(); +} diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/index.js b/app/assets/javascripts/pages/admin/broadcast_messages/index/index.js index ffd976be8c6..1f37df2b340 100644 --- a/app/assets/javascripts/pages/admin/broadcast_messages/index.js +++ b/app/assets/javascripts/pages/admin/broadcast_messages/index/index.js @@ -1,6 +1,6 @@ import initBroadcastMessages from '~/admin/broadcast_messages'; import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior'; -import initBroadcastMessagesForm from './broadcast_message'; +import initBroadcastMessagesForm from '../broadcast_message'; if (gon.features.vueBroadcastMessages) { initBroadcastMessages(); diff --git a/app/assets/javascripts/pages/admin/dashboard/index.js b/app/assets/javascripts/pages/admin/dashboard/index.js deleted file mode 100644 index b63e612be47..00000000000 --- a/app/assets/javascripts/pages/admin/dashboard/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import initGitlabVersionCheck from '~/gitlab_version_check'; - -initGitlabVersionCheck(); diff --git a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue index b06c804f3ca..48241a213ef 100644 --- a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue +++ b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue @@ -1,6 +1,7 @@ <script> -import { GlSafeHtmlDirective as SafeHtml, GlModal } from '@gitlab/ui'; +import { GlModal } from '@gitlab/ui'; import { escape } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __, s__, sprintf } from '~/locale'; export default { diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js index 2a7619da8cc..c5d62ae5daf 100644 --- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js +++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js @@ -6,9 +6,7 @@ import { getProjects } from '~/api/projects_api'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import { isMetaClick } from '~/lib/utils/common_utils'; import { addDelimiter } from '~/lib/utils/text_utility'; -import { visitUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import UsersSelect from '~/users_select'; @@ -34,10 +32,6 @@ export default class Todos { document.querySelectorAll('.js-todos-mark-all, .js-todos-undo-all').forEach((el) => { el.removeEventListener('click', this.updateallStateClickedWrapper); }); - document.querySelectorAll('.todo').forEach((el) => { - el.removeEventListener('click', this.goToTodoUrl); - el.removeEventListener('auxclick', this.goToTodoUrl); - }); } bindEvents() { @@ -50,10 +44,6 @@ export default class Todos { document.querySelectorAll('.js-todos-mark-all, .js-todos-undo-all').forEach((el) => { el.addEventListener('click', this.updateAllStateClickedWrapper); }); - document.querySelectorAll('.todo').forEach((el) => { - el.addEventListener('click', this.goToTodoUrl); - el.addEventListener('auxclick', this.goToTodoUrl); - }); } initFilters() { @@ -106,19 +96,22 @@ export default class Todos { e.stopPropagation(); e.preventDefault(); - const { target } = e; - target.setAttribute('disabled', true); - target.classList.add('disabled'); + let { currentTarget } = e; + if (currentTarget.tagName === 'svg' || currentTarget.tagName === 'use') { + currentTarget = currentTarget.closest('a'); + } + currentTarget.setAttribute('disabled', true); + currentTarget.classList.add('disabled'); - target.querySelector('.gl-spinner-container').classList.add('gl-mr-2'); + currentTarget.querySelector('.js-todo-button-icon').classList.add('hidden'); - axios[target.dataset.method](target.dataset.href) + axios[currentTarget.dataset.method](currentTarget.href) .then(({ data }) => { - this.updateRowState(target); + this.updateRowState(currentTarget); this.updateBadges(data); }) .catch(() => { - this.updateRowState(target, true); + this.updateRowState(currentTarget, true); return createAlert({ message: __('Error updating status of to-do item.'), }); @@ -134,7 +127,7 @@ export default class Todos { target.removeAttribute('disabled'); target.classList.remove('disabled'); - target.querySelector('.gl-spinner-container').classList.remove('gl-mr-2'); + target.querySelector('.js-todo-button-icon').classList.remove('hidden'); if (isInactive === true) { restoreBtn.classList.add('hidden'); @@ -209,25 +202,4 @@ export default class Todos { data.done_count, ); } - - goToTodoUrl(e) { - const todoLink = this.dataset.url; - - if (!todoLink || e.target.closest('a')) { - return; - } - - e.stopPropagation(); - e.preventDefault(); - - const isPrimaryClick = e.button === 0; - - if (isMetaClick(e)) { - const windowTarget = '_blank'; - - window.open(todoLink, windowTarget); - } else if (isPrimaryClick) { - visitUrl(todoLink); - } - } } diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js index 377ba0f13a9..bf0147ca885 100644 --- a/app/assets/javascripts/pages/groups/merge_requests/index.js +++ b/app/assets/javascripts/pages/groups/merge_requests/index.js @@ -1,11 +1,7 @@ import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; -import { - initBulkUpdateSidebar, - initStatusDropdown, - initSubscriptionsDropdown, -} from '~/issuable/bulk_update_sidebar'; import { FILTERED_SEARCH } from '~/filtered_search/constants'; +import { initBulkUpdateSidebar } from '~/issuable'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import projectSelect from '~/project_select'; @@ -13,8 +9,6 @@ const ISSUABLE_BULK_UPDATE_PREFIX = 'merge_request_'; addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); initBulkUpdateSidebar(ISSUABLE_BULK_UPDATE_PREFIX); -initStatusDropdown(); -initSubscriptionsDropdown(); initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, diff --git a/app/assets/javascripts/pages/help/index/index.js b/app/assets/javascripts/pages/help/index/index.js index a8e67c57307..da748223440 100644 --- a/app/assets/javascripts/pages/help/index/index.js +++ b/app/assets/javascripts/pages/help/index/index.js @@ -1,5 +1,3 @@ import docs from '~/docs/docs_bundle'; -import initGitlabVersionCheck from '~/gitlab_version_check'; docs(); -initGitlabVersionCheck(); diff --git a/app/assets/javascripts/pages/import/fogbugz/new_user_map/components/user_select.vue b/app/assets/javascripts/pages/import/fogbugz/new_user_map/components/user_select.vue index 20ce296bbec..912b84dbae6 100644 --- a/app/assets/javascripts/pages/import/fogbugz/new_user_map/components/user_select.vue +++ b/app/assets/javascripts/pages/import/fogbugz/new_user_map/components/user_select.vue @@ -1,5 +1,5 @@ <script> -import { GlAvatarLabeled, GlListbox } from '@gitlab/ui'; +import { GlAvatarLabeled, GlCollapsibleListbox } from '@gitlab/ui'; import { __ } from '~/locale'; import searchUsersQuery from '~/graphql_shared/queries/users_search_all.query.graphql'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -11,7 +11,7 @@ const USERS_PER_PAGE = 20; export default { components: { GlAvatarLabeled, - GlListbox, + GlCollapsibleListbox, }, props: { name: { @@ -70,7 +70,7 @@ export default { </script> <template> <div> - <gl-listbox + <gl-collapsible-listbox ref="listbox" v-model="user" :items="users" @@ -89,7 +89,7 @@ export default { :sub-label="item.username" /> </template> - </gl-listbox> + </gl-collapsible-listbox> <input type="hidden" :name="name" :value="userId" /> </div> </template> diff --git a/app/assets/javascripts/pages/import/gitlab_projects/new/index.js b/app/assets/javascripts/pages/import/gitlab_projects/new/index.js index 870c14f99ae..d0560af5b3f 100644 --- a/app/assets/javascripts/pages/import/gitlab_projects/new/index.js +++ b/app/assets/javascripts/pages/import/gitlab_projects/new/index.js @@ -1,3 +1,5 @@ import initGitLabImportProject from '~/projects/project_import_gitlab_project'; +import { initNewProjectUrlSelect } from '~/projects/new'; +initNewProjectUrlSelect(); initGitLabImportProject(); diff --git a/app/assets/javascripts/pages/import/manifest/new/index.js b/app/assets/javascripts/pages/import/manifest/new/index.js new file mode 100644 index 00000000000..0bb70a7364e --- /dev/null +++ b/app/assets/javascripts/pages/import/manifest/new/index.js @@ -0,0 +1,3 @@ +import { initNewProjectUrlSelect } from '~/projects/new'; + +initNewProjectUrlSelect(); diff --git a/app/assets/javascripts/pages/import/phabricator/new/index.js b/app/assets/javascripts/pages/import/phabricator/new/index.js new file mode 100644 index 00000000000..0bb70a7364e --- /dev/null +++ b/app/assets/javascripts/pages/import/phabricator/new/index.js @@ -0,0 +1,3 @@ +import { initNewProjectUrlSelect } from '~/projects/new'; + +initNewProjectUrlSelect(); diff --git a/app/assets/javascripts/pages/projects/branches/new/index.js b/app/assets/javascripts/pages/projects/branches/new/index.js index dbae89b5ade..f2b03468b0b 100644 --- a/app/assets/javascripts/pages/projects/branches/new/index.js +++ b/app/assets/javascripts/pages/projects/branches/new/index.js @@ -1,7 +1,6 @@ import NewBranchForm from '~/new_branch_form'; +import initNewBranchRefSelector from '~/branches/init_new_branch_ref_selector'; +initNewBranchRefSelector(); // eslint-disable-next-line no-new -new NewBranchForm( - document.querySelector('.js-create-branch-form'), - JSON.parse(document.getElementById('availableRefs').innerHTML), -); +new NewBranchForm(document.querySelector('.js-create-branch-form')); diff --git a/app/assets/javascripts/pages/projects/ci/lints/show/index.js b/app/assets/javascripts/pages/projects/ci/lints/show/index.js index 6e1cdf557b5..caac76fc6d7 100644 --- a/app/assets/javascripts/pages/projects/ci/lints/show/index.js +++ b/app/assets/javascripts/pages/projects/ci/lints/show/index.js @@ -1,3 +1,3 @@ -import initCiLint from '~/ci_lint'; +import initCiLint from '~/ci/ci_lint'; initCiLint(); diff --git a/app/assets/javascripts/pages/projects/ci/pipeline_editor/show/index.js b/app/assets/javascripts/pages/projects/ci/pipeline_editor/show/index.js index 67d32648ce8..7e91f23dd7f 100644 --- a/app/assets/javascripts/pages/projects/ci/pipeline_editor/show/index.js +++ b/app/assets/javascripts/pages/projects/ci/pipeline_editor/show/index.js @@ -1,3 +1,3 @@ -import { initPipelineEditor } from '~/pipeline_editor'; +import { initPipelineEditor } from '~/ci/pipeline_editor'; initPipelineEditor(); diff --git a/app/assets/javascripts/pages/projects/commits/show/index.js b/app/assets/javascripts/pages/projects/commits/show/index.js index ee74628a994..f5ecf9be591 100644 --- a/app/assets/javascripts/pages/projects/commits/show/index.js +++ b/app/assets/javascripts/pages/projects/commits/show/index.js @@ -1,9 +1,10 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import CommitsList from '~/commits'; import GpgBadges from '~/gpg_badges'; -import mountCommits from '~/projects/commits'; +import { mountCommits, initCommitsRefSwitcher } from '~/projects/commits'; 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')); +initCommitsRefSwitcher(); diff --git a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js index bef21ef8fdf..05a1bbc69ed 100644 --- a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js +++ b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js @@ -1,3 +1,3 @@ -import initCycleAnalytics from '~/cycle_analytics'; +import initCycleAnalytics from '~/analytics/cycle_analytics'; initCycleAnalytics(); diff --git a/app/assets/javascripts/pages/projects/environments/show/index.js b/app/assets/javascripts/pages/projects/environments/show/index.js index 53e48ad8d86..1ce8899ac63 100644 --- a/app/assets/javascripts/pages/projects/environments/show/index.js +++ b/app/assets/javascripts/pages/projects/environments/show/index.js @@ -1,5 +1,6 @@ import initConfirmRollBackModal from '~/environments/init_confirm_rollback_modal'; -import { initHeader } from '~/environments/mount_show'; +import { initHeader, initPage } from '~/environments/mount_show'; initHeader(); +initPage(); initConfirmRollBackModal(); diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue index 30cefa3d717..91650003d4a 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue @@ -23,6 +23,7 @@ import { VISIBILITY_LEVEL_INTERNAL_STRING, VISIBILITY_LEVEL_PUBLIC_STRING, VISIBILITY_LEVELS_STRING_TO_INTEGER, + VISIBILITY_LEVELS_INTEGER_TO_STRING, } from '~/visibility_level/constants'; import ProjectNamespace from './project_namespace.vue'; @@ -105,39 +106,8 @@ export default { }; }, computed: { - projectVisibilityLevel() { - return VISIBILITY_LEVELS_STRING_TO_INTEGER[this.projectVisibility]; - }, - namespaceVisibilityLevel() { - const visibility = - this.form.fields.namespace.value?.visibility || VISIBILITY_LEVEL_PUBLIC_STRING; - return VISIBILITY_LEVELS_STRING_TO_INTEGER[visibility]; - }, - visibilityLevelCap() { - return Math.min(this.projectVisibilityLevel, this.namespaceVisibilityLevel); - }, - restrictedVisibilityLevelsSet() { - return new Set(this.restrictedVisibilityLevels); - }, allowedVisibilityLevels() { - const allowedLevels = Object.entries(VISIBILITY_LEVELS_STRING_TO_INTEGER).reduce( - (levels, [levelName, levelValue]) => { - if ( - !this.restrictedVisibilityLevelsSet.has(levelValue) && - levelValue <= this.visibilityLevelCap - ) { - levels.push(levelName); - } - return levels; - }, - [], - ); - - if (!allowedLevels.length) { - return [VISIBILITY_LEVEL_PRIVATE_STRING]; - } - - return allowedLevels; + return this.getAllowedVisibilityLevels(); }, visibilityLevels() { return [ @@ -178,13 +148,60 @@ export default { return !this.allowedVisibilityLevels.includes(visibility); }, getInitialVisibilityValue() { - return this.restrictedVisibilityLevels.length !== 0 ? null : this.projectVisibility; + return this.getMaximumAllowedVisibilityLevel(this.projectVisibility); }, setNamespace(namespace) { - this.form.fields.visibility.value = - this.restrictedVisibilityLevels.length !== 0 ? null : VISIBILITY_LEVEL_PRIVATE_STRING; this.form.fields.namespace.value = namespace; this.form.fields.namespace.state = true; + this.form.fields.visibility.value = this.getMaximumAllowedVisibilityLevel( + this.form.fields.visibility.value, + ); + }, + getProjectVisibilityLevel() { + return VISIBILITY_LEVELS_STRING_TO_INTEGER[this.projectVisibility]; + }, + getNamespaceVisibilityLevel() { + const visibility = + this.form?.fields?.namespace?.value?.visibility || VISIBILITY_LEVEL_PUBLIC_STRING; + return VISIBILITY_LEVELS_STRING_TO_INTEGER[visibility]; + }, + getVisibilityLevelCap() { + return Math.min(this.getProjectVisibilityLevel(), this.getNamespaceVisibilityLevel()); + }, + getRestrictedVisibilityLevelsSet() { + return new Set(this.restrictedVisibilityLevels); + }, + getAllowedVisibilityLevels() { + const allowedLevels = Object.entries(VISIBILITY_LEVELS_STRING_TO_INTEGER).reduce( + (levels, [levelName, levelValue]) => { + if ( + !this.getRestrictedVisibilityLevelsSet().has(levelValue) && + levelValue <= this.getVisibilityLevelCap() + ) { + levels.push(levelName); + } + return levels; + }, + [], + ); + + if (!allowedLevels.length) { + return [VISIBILITY_LEVEL_PRIVATE_STRING]; + } + + return allowedLevels; + }, + getMaximumAllowedVisibilityLevel(visibility) { + const allowedVisibilities = this.getAllowedVisibilityLevels().map( + (s) => VISIBILITY_LEVELS_STRING_TO_INTEGER[s], + ); + const current = VISIBILITY_LEVELS_STRING_TO_INTEGER[visibility]; + const lower = allowedVisibilities.filter((l) => l <= current); + if (lower.length) { + return VISIBILITY_LEVELS_INTEGER_TO_STRING[Math.max(...lower)]; + } + const higher = allowedVisibilities.filter((l) => l >= current); + return VISIBILITY_LEVELS_INTEGER_TO_STRING[Math.min(...higher)]; }, async onSubmit() { this.form.showValidation = true; diff --git a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue index 08d24344ffc..10bfcdc2294 100644 --- a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue +++ b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue @@ -1,5 +1,5 @@ <script> -import { GlAlert, GlButton, GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlButton, GlListbox, GlSprintf } from '@gitlab/ui'; import { GlAreaChart } from '@gitlab/ui/dist/charts'; import { get } from 'lodash'; import { formatDate } from '~/lib/utils/datetime_utility'; @@ -12,8 +12,7 @@ export default { GlAlert, GlAreaChart, GlButton, - GlDropdown, - GlDropdownItem, + GlListbox, GlSprintf, }, props: { @@ -96,6 +95,14 @@ export default { formattedData() { return this.sortedData.map((value) => [value.date, value.coverage]); }, + mappedCoverages() { + return this.dailyCoverageData?.map((item, index) => ({ + // A numerical index makes an item into a group header, so + // convert these to strings to get non-header GlListbox items + value: index.toString(), + text: item.group_name, + })); + }, chartData() { return [ { @@ -175,18 +182,13 @@ export default { {{ __('It seems that there is currently no available data for code coverage') }} </span> </gl-alert> - <gl-dropdown v-if="canShowData" :text="selectedDailyCoverageName"> - <gl-dropdown-item - v-for="({ group_name }, index) in dailyCoverageData" - :key="index" - :value="group_name" - is-check-item - :is-checked="index === selectedCoverageIndex" - @click="setSelectedCoverage(index)" - > - {{ group_name }} - </gl-dropdown-item> - </gl-dropdown> + <gl-listbox + v-if="canShowData" + :items="mappedCoverages" + :selected="selectedCoverageIndex.toString()" + :toggle-text="selectedDailyCoverageName" + @select="setSelectedCoverage" + /> </div> <gl-area-chart v-if="!isLoading" diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js index 2d26d3922bf..653f903c6d1 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js @@ -26,7 +26,10 @@ const updateCommitList = (url, $emptyState, $loadingIndicator, $commitList, para export default (mrNewCompareNode) => { const { sourceBranchUrl, targetBranchUrl } = mrNewCompareNode.dataset; - initTargetProjectDropdown(); + + if (!window.gon?.features?.mrCompareDropdowns) { + initTargetProjectDropdown(); + } const updateSourceBranchCommitList = () => updateCommitList( diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js index 9aecd154483..b3868653d6a 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js @@ -1,10 +1,37 @@ +import $ from 'jquery'; +import Vue from 'vue'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; import MergeRequest from '~/merge_request'; +import TargetProjectDropdown from '~/merge_requests/components/target_project_dropdown.vue'; import initCompare from './compare'; const mrNewCompareNode = document.querySelector('.js-merge-request-new-compare'); if (mrNewCompareNode) { initCompare(mrNewCompareNode); + + const el = document.getElementById('js-target-project-dropdown'); + const { targetProjectsPath, currentProject } = el.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el, + name: 'TargetProjectDropdown', + provide: { + targetProjectsPath, + currentProject: JSON.parse(currentProject), + }, + render(h) { + return h(TargetProjectDropdown, { + on: { + 'project-selected': function projectSelectedFunction(refsUrl) { + const $targetBranchDropdown = $('.js-target-branch'); + $targetBranchDropdown.data('refsUrl', refsUrl); + $targetBranchDropdown.data('deprecatedJQueryDropdown').clearMenu(); + }, + }, + }); + }, + }); } else { const mrNewSubmitNode = document.querySelector('.js-merge-request-new-submit'); // eslint-disable-next-line no-new diff --git a/app/assets/javascripts/pages/projects/merge_requests/diffs/index.js b/app/assets/javascripts/pages/projects/merge_requests/diffs/index.js new file mode 100644 index 00000000000..77294c0fb9e --- /dev/null +++ b/app/assets/javascripts/pages/projects/merge_requests/diffs/index.js @@ -0,0 +1,5 @@ +import initDiffsApp from '~/diffs'; +import { initMrPage } from '../page'; + +initMrPage(); +initDiffsApp(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js index 2399aafc9b5..b3a09cc0be3 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js @@ -1,20 +1,13 @@ import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; -import { initCsvImportExportButtons, initIssuableByEmail } from '~/issuable'; -import { - initBulkUpdateSidebar, - initStatusDropdown, - initSubscriptionsDropdown, -} from '~/issuable/bulk_update_sidebar'; import { FILTERED_SEARCH } from '~/filtered_search/constants'; +import { initBulkUpdateSidebar, initCsvImportExportButtons, initIssuableByEmail } from '~/issuable'; import { ISSUABLE_INDEX } from '~/issuable/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import UsersSelect from '~/users_select'; initBulkUpdateSidebar(ISSUABLE_INDEX.MERGE_REQUEST); -initStatusDropdown(); -initSubscriptionsDropdown(); addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); IssuableFilteredSearchTokenKeys.removeTokensForKeys('iteration'); diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js index 42fa306d226..a4e3ddfc506 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import IssuableForm from 'ee_else_ce/issuable/issuable_form'; +import IssuableLabelSelector from '~/issuable/issuable_label_selector'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import Diff from '~/diff'; import GLForm from '~/gl_form'; @@ -14,6 +15,7 @@ export default () => { new ShortcutsNavigation(); new GLForm($('.merge-request-form')); new IssuableForm($('.merge-request-form')); + IssuableLabelSelector(); new LabelsSelect(); new IssuableTemplateSelectors({ warnTemplateOverride: true, diff --git a/app/assets/javascripts/pages/projects/merge_requests/page.js b/app/assets/javascripts/pages/projects/merge_requests/page.js new file mode 100644 index 00000000000..a8699b350f8 --- /dev/null +++ b/app/assets/javascripts/pages/projects/merge_requests/page.js @@ -0,0 +1,45 @@ +import Vue from 'vue'; +import StickyHeader from '~/merge_requests/components/sticky_header.vue'; +import { initIssuableHeaderWarnings } from '~/issuable'; +import initMrNotes from '~/mr_notes'; +import store from '~/mr_notes/stores'; +import initSidebarBundle from '~/sidebar/sidebar_bundle'; +import { apolloProvider } from '~/graphql_shared/issuable_client'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import initShow from './init_merge_request_show'; +import getStateQuery from './queries/get_state.query.graphql'; + +export function initMrPage() { + initMrNotes(); + initShow(); +} + +requestIdleCallback(() => { + initSidebarBundle(store); + initIssuableHeaderWarnings(store); + + const el = document.getElementById('js-merge-sticky-header'); + + if (el) { + const { data } = el.dataset; + const { iid, projectPath, title, tabs, isFluidLayout } = JSON.parse(data); + + // eslint-disable-next-line no-new + new Vue({ + el, + store, + apolloProvider, + provide: { + query: getStateQuery, + iid, + projectPath, + title, + tabs, + isFluidLayout: parseBoolean(isFluidLayout), + }, + render(h) { + return h(StickyHeader); + }, + }); + } +}); diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js index cc5c393ff8c..568bf19b55e 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js @@ -1,45 +1,5 @@ -import Vue from 'vue'; -import StickyHeader from '~/merge_requests/components/sticky_header.vue'; -import { initReviewBar } from '~/batch_comments'; -import { initIssuableHeaderWarnings } from '~/issuable'; -import initMrNotes from '~/mr_notes'; -import store from '~/mr_notes/stores'; -import initSidebarBundle from '~/sidebar/sidebar_bundle'; -import { apolloProvider } from '~/graphql_shared/issuable_client'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import initShow from '../init_merge_request_show'; -import getStateQuery from '../queries/get_state.query.graphql'; +import initNotesApp from '~/mr_notes/init_notes'; +import { initMrPage } from '../page'; -initMrNotes(); -initShow(); - -requestIdleCallback(() => { - initSidebarBundle(store); - initReviewBar(); - initIssuableHeaderWarnings(store); - - const el = document.getElementById('js-merge-sticky-header'); - - if (el) { - const { data } = el.dataset; - const { iid, projectPath, title, tabs, isFluidLayout } = JSON.parse(data); - - // eslint-disable-next-line no-new - new Vue({ - el, - store, - apolloProvider, - provide: { - query: getStateQuery, - iid, - projectPath, - title, - tabs, - isFluidLayout: parseBoolean(isFluidLayout), - }, - render(h) { - return h(StickyHeader); - }, - }); - } -}); +initMrPage(); +initNotesApp(); diff --git a/app/assets/javascripts/pages/projects/ml/candidates/show/index.js b/app/assets/javascripts/pages/projects/ml/candidates/show/index.js new file mode 100644 index 00000000000..c1acef5ac13 --- /dev/null +++ b/app/assets/javascripts/pages/projects/ml/candidates/show/index.js @@ -0,0 +1,27 @@ +import Vue from 'vue'; +import MlCandidate from '~/ml/experiment_tracking/components/ml_candidate.vue'; + +const initShowCandidate = () => { + const element = document.querySelector('#js-show-ml-candidate'); + if (!element) { + return; + } + + const container = document.createElement('div'); + element.appendChild(container); + + const candidate = JSON.parse(element.dataset.candidate); + + // eslint-disable-next-line no-new + new Vue({ + el: container, + provide: { + candidate, + }, + render(h) { + return h(MlCandidate); + }, + }); +}; + +initShowCandidate(); diff --git a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js index 0a9d9f4c987..97e436920c7 100644 --- a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js +++ b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import ShowExperiment from '~/ml/experiment_tracking/components/experiment.vue'; +import MlExperiment from '~/ml/experiment_tracking/components/ml_experiment.vue'; const initShowExperiment = () => { const element = document.querySelector('#js-show-ml-experiment'); @@ -23,7 +23,7 @@ const initShowExperiment = () => { paramNames, }, render(h) { - return h(ShowExperiment); + return h(MlExperiment); }, }); }; diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js index 50733d8a145..d022428df98 100644 --- a/app/assets/javascripts/pages/projects/new/index.js +++ b/app/assets/javascripts/pages/projects/new/index.js @@ -4,10 +4,8 @@ import { initDeploymentTargetSelect, } from '~/projects/new'; import initProjectVisibilitySelector from '~/projects/project_visibility'; -import initProjectNew from '~/projects/project_new'; initProjectVisibilitySelector(); -initProjectNew.bindEvents(); initNewProjectCreation(); initNewProjectUrlSelect(); initDeploymentTargetSelect(); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue index 85443843684..fd8b1a6290f 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue @@ -39,6 +39,11 @@ export default { required: false, default: '', }, + sendNativeErrors: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -114,9 +119,11 @@ export default { cronInterval() { // updates field validation state when model changes, as // glFieldError only updates on input. - this.$nextTick(() => { - gl.pipelineScheduleFieldErrors.updateFormValidityState(); - }); + if (this.sendNativeErrors) { + this.$nextTick(() => { + gl.pipelineScheduleFieldErrors.updateFormValidityState(); + }); + } }, radioValue: { immediate: true, diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js deleted file mode 100644 index bc467952551..00000000000 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js +++ /dev/null @@ -1,82 +0,0 @@ -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { formatTimezone } from '~/lib/utils/datetime_utility'; - -const defaultTimezone = { identifier: 'Etc/UTC', name: 'UTC', offset: 0 }; -const defaults = { - $inputEl: null, - $dropdownEl: null, - onSelectTimezone: null, - displayFormat: (item) => item.name, -}; - -export const formatUtcOffset = (offset) => { - const parsed = parseInt(offset, 10); - if (Number.isNaN(parsed) || parsed === 0) { - return `0`; - } - const prefix = offset > 0 ? '+' : '-'; - return `${prefix} ${Math.abs(offset / 3600)}`; -}; - -export const findTimezoneByIdentifier = (tzList = [], identifier = null) => { - if (tzList && tzList.length && identifier && identifier.length) { - return tzList.find((tz) => tz.identifier === identifier) || null; - } - return null; -}; - -export default class TimezoneDropdown { - constructor({ - $dropdownEl, - $inputEl, - onSelectTimezone, - displayFormat, - allowEmpty = false, - } = defaults) { - this.$dropdown = $dropdownEl; - this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text'); - this.$input = $inputEl; - this.timezoneData = this.$dropdown.data('data') || []; - - this.onSelectTimezone = onSelectTimezone; - this.displayFormat = displayFormat || defaults.displayFormat; - this.allowEmpty = allowEmpty; - - this.initDropdown(); - } - - initDropdown() { - initDeprecatedJQueryDropdown(this.$dropdown, { - data: this.timezoneData, - filterable: true, - selectable: true, - toggleLabel: this.displayFormat, - search: { - fields: ['name'], - }, - clicked: (cfg) => this.handleDropdownChange(cfg), - text: (item) => formatTimezone(item), - }); - - const initialTimezone = findTimezoneByIdentifier(this.timezoneData, this.$input.val()); - - if (initialTimezone !== null) { - this.setDropdownValue(initialTimezone); - } else if (!this.allowEmpty) { - this.setDropdownValue(defaultTimezone); - } - } - - setDropdownValue(timezone) { - this.$dropdownToggle.text(this.displayFormat(timezone)); - this.$input.val(timezone.identifier); - } - - handleDropdownChange({ selectedObj, e }) { - e.preventDefault(); - this.$input.val(selectedObj.identifier); - if (this.onSelectTimezone) { - this.onSelectTimezone({ selectedObj, e }); - } - } -} diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index d177c67f133..4c9eb830ff6 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -11,10 +11,14 @@ import { mergeUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import projectSelect from '~/project_select'; +const BRANCH_REF_TYPE = 'heads'; +const TAG_REF_TYPE = 'tags'; +const BRANCH_GROUP_NAME = __('Branches'); +const TAG_GROUP_NAME = __('Tags'); + export default class Project { constructor() { initClonePanel(); - // Ref switcher if (document.querySelector('.js-project-refs-dropdown')) { Project.initRefSwitcher(); @@ -62,6 +66,7 @@ export default class Project { return $('.js-project-refs-dropdown').each(function () { const $dropdown = $(this); const selected = $dropdown.data('selected'); + const refType = $dropdown.data('refType'); const fieldName = $dropdown.data('fieldName'); const shouldVisit = Boolean($dropdown.data('visit')); const $form = $dropdown.closest('form'); @@ -91,18 +96,32 @@ export default class Project { filterByText: true, inputFieldName: $dropdown.data('inputFieldName'), fieldName, - renderRow(ref) { + renderRow(ref, _, params) { const li = refListItem.cloneNode(false); const link = refLink.cloneNode(false); if (ref === selected) { - link.className = 'is-active'; + // Check group and current ref type to avoid adding a class when tags and branches share the same name + if ( + (refType === BRANCH_REF_TYPE && params.group === BRANCH_GROUP_NAME) || + (refType === TAG_REF_TYPE && params.group === TAG_GROUP_NAME) || + !refType + ) { + link.className = 'is-active'; + } } + link.textContent = ref; link.dataset.ref = ref; if (ref.length > 0 && shouldVisit) { - link.href = mergeUrlParams({ [fieldName]: ref }, linkTarget); + const urlParams = { [fieldName]: ref }; + if (params.group === BRANCH_GROUP_NAME) { + urlParams.ref_type = BRANCH_REF_TYPE; + } else { + urlParams.ref_type = TAG_REF_TYPE; + } + link.href = mergeUrlParams(urlParams, linkTarget); } li.appendChild(link); diff --git a/app/assets/javascripts/pages/projects/settings/merge_requests/index.js b/app/assets/javascripts/pages/projects/settings/merge_requests/index.js index 739e666644c..0f7ede8ed42 100644 --- a/app/assets/javascripts/pages/projects/settings/merge_requests/index.js +++ b/app/assets/javascripts/pages/projects/settings/merge_requests/index.js @@ -1,9 +1,6 @@ import groupsSelect from '~/groups_select'; import UserCallout from '~/user_callout'; -import UsersSelect from '~/users_select'; -// eslint-disable-next-line no-new -new UsersSelect(); groupsSelect(); // eslint-disable-next-line no-new 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 c37b4cc643a..5fa3288bbef 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 @@ -23,6 +23,12 @@ import ProjectSettingRow from './project_setting_row.vue'; const FEATURE_ACCESS_LEVEL_ANONYMOUS = [30, s__('ProjectSettings|Everyone')]; +const PACKAGE_REGISTRY_ACCESS_LEVEL_DEFAULT_BY_PROJECT_VISIBILITY = { + [VISIBILITY_LEVEL_PRIVATE_INTEGER]: featureAccessLevel.PROJECT_MEMBERS, + [VISIBILITY_LEVEL_INTERNAL_INTEGER]: featureAccessLevel.EVERYONE, + [VISIBILITY_LEVEL_PUBLIC_INTEGER]: FEATURE_ACCESS_LEVEL_ANONYMOUS[0], +}; + export default { i18n: { ...CVE_ID_REQUEST_BUTTON_I18N, @@ -32,7 +38,6 @@ export default { issuesLabel: s__('ProjectSettings|Issues'), lfsLabel: s__('ProjectSettings|Git Large File Storage (LFS)'), mergeRequestsLabel: s__('ProjectSettings|Merge requests'), - operationsLabel: s__('ProjectSettings|Operations'), environmentsLabel: s__('ProjectSettings|Environments'), environmentsHelpText: s__( 'ProjectSettings|Every project can make deployments to environments either via CI/CD or API calls. Non-project members have read-only access.', @@ -47,11 +52,15 @@ export default { packagesHelpText: s__( 'ProjectSettings|Every project can have its own space to store its packages. Note: The Package Registry is always visible when a project is public.', ), - packageRegistryHelpText: s__( - 'ProjectSettings|Every project can have its own space to store its packages.', + packageRegistryHelpText: s__('ProjectSettings|Publish, store, and view packages in a project.'), + packageRegistryForEveryoneHelpText: s__( + 'ProjectSettings|Anyone can pull packages with a package manager API.', ), packagesLabel: s__('ProjectSettings|Packages'), packageRegistryLabel: s__('ProjectSettings|Package registry'), + packageRegistryForEveryoneLabel: s__( + 'ProjectSettings|Allow anyone to pull from Package Registry', + ), pagesLabel: s__('ProjectSettings|Pages'), ciCdLabel: __('CI/CD'), repositoryLabel: s__('ProjectSettings|Repository'), @@ -249,7 +258,6 @@ export default { analyticsAccessLevel: featureAccessLevel.EVERYONE, requirementsAccessLevel: featureAccessLevel.EVERYONE, securityAndComplianceAccessLevel: featureAccessLevel.PROJECT_MEMBERS, - operationsAccessLevel: featureAccessLevel.EVERYONE, environmentsAccessLevel: featureAccessLevel.EVERYONE, featureFlagsAccessLevel: featureAccessLevel.PROJECT_MEMBERS, infrastructureAccessLevel: featureAccessLevel.PROJECT_MEMBERS, @@ -287,18 +295,6 @@ export default { ); }, - packageRegistryFeatureAccessLevelOptions() { - const options = [FEATURE_ACCESS_LEVEL_ANONYMOUS]; - - if (this.visibilityLevel === VISIBILITY_LEVEL_PRIVATE_INTEGER) { - options.unshift(featureAccessLevelMembers); - } else if (this.visibilityLevel === VISIBILITY_LEVEL_INTERNAL_INTEGER) { - options.unshift(featureAccessLevelEveryone); - } - - return options; - }, - pagesFeatureAccessLevelOptions() { const options = [featureAccessLevelMembers]; @@ -318,10 +314,6 @@ export default { return options; }, - operationsEnabled() { - return this.operationsAccessLevel > featureAccessLevel.NOT_ENABLED; - }, - environmentsEnabled() { return this.environmentsAccessLevel > featureAccessLevel.NOT_ENABLED; }, @@ -351,7 +343,7 @@ export default { } return s__( - 'ProjectSettings|View and edit files in this project. Non-project members have only read access.', + 'ProjectSettings|View and edit files in this project. When set to **Everyone With Access** non-project members have only read access.', ); }, cveIdRequestIsDisabled() { @@ -366,16 +358,17 @@ export default { packageRegistryAccessLevelEnabled() { return this.glFeatures.packageRegistryAccessLevel; }, - splitOperationsEnabled() { - return this.glFeatures.splitOperationsVisibilityPermissions; + packageRegistryEnabled() { + return this.packageRegistryAccessLevel > featureAccessLevel.NOT_ENABLED; + }, + packageRegistryApiForEveryoneEnabled() { + return this.packageRegistryAccessLevel === FEATURE_ACCESS_LEVEL_ANONYMOUS[0]; + }, + packageRegistryApiForEveryoneEnabledShown() { + return this.visibilityLevel !== VISIBILITY_LEVEL_PUBLIC_INTEGER; }, monitorOperationsFeatureAccessLevelOptions() { - if (this.splitOperationsEnabled) { - return this.featureAccessLevelOptions.filter(([value]) => value <= this.monitorAccessLevel); - } - return this.featureAccessLevelOptions.filter( - ([value]) => value <= this.operationsAccessLevel, - ); + return this.featureAccessLevelOptions.filter(([value]) => value <= this.monitorAccessLevel); }, }, @@ -429,10 +422,6 @@ export default { featureAccessLevel.PROJECT_MEMBERS, this.securityAndComplianceAccessLevel, ); - this.operationsAccessLevel = Math.min( - featureAccessLevel.PROJECT_MEMBERS, - this.operationsAccessLevel, - ); this.environmentsAccessLevel = Math.min( featureAccessLevel.PROJECT_MEMBERS, this.environmentsAccessLevel, @@ -474,9 +463,8 @@ export default { this.packageRegistryAccessLevelEnabled && this.packageRegistryAccessLevel === featureAccessLevel.PROJECT_MEMBERS ) { - this.packageRegistryAccessLevel = Math.min( - ...this.packageRegistryFeatureAccessLevelOptions.map((option) => option[0]), - ); + this.packageRegistryAccessLevel = + PACKAGE_REGISTRY_ACCESS_LEVEL_DEFAULT_BY_PROJECT_VISIBILITY[value]; } if (this.buildsAccessLevel > featureAccessLevel.NOT_ENABLED) this.buildsAccessLevel = featureAccessLevel.EVERYONE; @@ -492,8 +480,6 @@ export default { this.metricsDashboardAccessLevel = featureAccessLevel.EVERYONE; if (this.requirementsAccessLevel === featureAccessLevel.PROJECT_MEMBERS) this.requirementsAccessLevel = featureAccessLevel.EVERYONE; - if (this.operationsAccessLevel === featureAccessLevel.PROJECT_MEMBERS) - this.operationsAccessLevel = featureAccessLevel.EVERYONE; if (this.environmentsAccessLevel === featureAccessLevel.PROJECT_MEMBERS) this.environmentsAccessLevel = featureAccessLevel.EVERYONE; if (this.monitorAccessLevel === featureAccessLevel.PROJECT_MEMBERS) @@ -532,10 +518,6 @@ export default { toggleHiddenClassBySelector('.merge-requests-feature', false); }, - operationsAccessLevel(value, oldValue) { - this.updateSubFeatureAccessLevel(value, oldValue); - }, - monitorAccessLevel(value, oldValue) { this.updateSubFeatureAccessLevel(value, oldValue); }, @@ -561,6 +543,22 @@ export default { visibilityAllowed(option) { return this.allowedVisibilityOptions.includes(option); }, + onPackageRegistryEnabledToggle(value) { + this.packageRegistryAccessLevel = value + ? this.packageRegistryAccessLevelDefault() + : featureAccessLevel.NOT_ENABLED; + }, + onPackageRegistryApiForEveryoneEnabledToggle(value) { + this.packageRegistryAccessLevel = value + ? FEATURE_ACCESS_LEVEL_ANONYMOUS[0] + : this.packageRegistryAccessLevelDefault(); + }, + packageRegistryAccessLevelDefault() { + return ( + PACKAGE_REGISTRY_ACCESS_LEVEL_DEFAULT_BY_PROJECT_VISIBILITY[this.visibilityLevel] ?? + featureAccessLevel.NOT_ENABLED + ); + }, }, }; </script> @@ -897,10 +895,36 @@ export default { :help-text="$options.i18n.packageRegistryHelpText" data-testid="package-registry-access-level" > - <project-feature-setting - v-model="packageRegistryAccessLevel" + <gl-toggle + class="gl-my-2" + :value="packageRegistryEnabled" :label="$options.i18n.packageRegistryLabel" - :options="packageRegistryFeatureAccessLevelOptions" + label-position="hidden" + name="package_registry_enabled" + @change="onPackageRegistryEnabledToggle" + /> + <div + v-if="packageRegistryApiForEveryoneEnabledShown" + class="project-feature-setting-group gl-pl-7 gl-sm-pl-5 gl-my-3" + > + <project-setting-row + :label="$options.i18n.packageRegistryForEveryoneLabel" + :help-text="$options.i18n.packageRegistryForEveryoneHelpText" + > + <gl-toggle + class="gl-my-2" + :value="packageRegistryApiForEveryoneEnabled" + :disabled="!packageRegistryEnabled" + :label="$options.i18n.packageRegistryForEveryoneLabel" + label-position="hidden" + name="package_registry_api_for_everyone_enabled" + @change="onPackageRegistryApiForEveryoneEnabledToggle" + /> + </project-setting-row> + </div> + <input + :value="packageRegistryAccessLevel" + type="hidden" name="project[project_feature_attributes][package_registry_access_level]" /> </project-setting-row> @@ -923,11 +947,10 @@ export default { /> </project-setting-row> <project-setting-row - v-if="splitOperationsEnabled" ref="monitor-settings" :label="$options.i18n.monitorLabel" :help-text=" - s__('ProjectSettings|Configure your project resources and monitor their health.') + s__('ProjectSettings|Monitor the health of your project and respond to incidents.') " > <project-feature-setting @@ -937,21 +960,6 @@ export default { name="project[project_feature_attributes][monitor_access_level]" /> </project-setting-row> - <project-setting-row - v-else - ref="operations-settings" - :label="$options.i18n.operationsLabel" - :help-text=" - s__('ProjectSettings|Configure your project resources and monitor their health.') - " - > - <project-feature-setting - v-model="operationsAccessLevel" - :label="$options.i18n.operationsLabel" - :options="featureAccessLevelOptions" - name="project[project_feature_attributes][operations_access_level]" - /> - </project-setting-row> <div class="project-feature-setting-group gl-pl-7 gl-sm-pl-5"> <project-setting-row ref="metrics-visibility-settings" @@ -966,47 +974,45 @@ export default { /> </project-setting-row> </div> - <template v-if="splitOperationsEnabled"> - <project-setting-row - ref="environments-settings" + <project-setting-row + ref="environments-settings" + :label="$options.i18n.environmentsLabel" + :help-text="$options.i18n.environmentsHelpText" + :help-path="environmentsHelpPath" + > + <project-feature-setting + v-model="environmentsAccessLevel" :label="$options.i18n.environmentsLabel" - :help-text="$options.i18n.environmentsHelpText" - :help-path="environmentsHelpPath" - > - <project-feature-setting - v-model="environmentsAccessLevel" - :label="$options.i18n.environmentsLabel" - :options="featureAccessLevelOptions" - name="project[project_feature_attributes][environments_access_level]" - /> - </project-setting-row> - <project-setting-row - ref="feature-flags-settings" + :options="featureAccessLevelOptions" + name="project[project_feature_attributes][environments_access_level]" + /> + </project-setting-row> + <project-setting-row + ref="feature-flags-settings" + :label="$options.i18n.featureFlagsLabel" + :help-text="$options.i18n.featureFlagsHelpText" + :help-path="featureFlagsHelpPath" + > + <project-feature-setting + v-model="featureFlagsAccessLevel" :label="$options.i18n.featureFlagsLabel" - :help-text="$options.i18n.featureFlagsHelpText" - :help-path="featureFlagsHelpPath" - > - <project-feature-setting - v-model="featureFlagsAccessLevel" - :label="$options.i18n.featureFlagsLabel" - :options="featureAccessLevelOptions" - name="project[project_feature_attributes][feature_flags_access_level]" - /> - </project-setting-row> - <project-setting-row - ref="infrastructure-settings" + :options="featureAccessLevelOptions" + name="project[project_feature_attributes][feature_flags_access_level]" + /> + </project-setting-row> + <project-setting-row + ref="infrastructure-settings" + :label="$options.i18n.infrastructureLabel" + :help-text="$options.i18n.infrastructureHelpText" + :help-path="infrastructureHelpPath" + > + <project-feature-setting + v-model="infrastructureAccessLevel" :label="$options.i18n.infrastructureLabel" - :help-text="$options.i18n.infrastructureHelpText" - :help-path="infrastructureHelpPath" - > - <project-feature-setting - v-model="infrastructureAccessLevel" - :label="$options.i18n.infrastructureLabel" - :options="featureAccessLevelOptions" - name="project[project_feature_attributes][infrastructure_access_level]" - /> - </project-setting-row> - </template> + :options="featureAccessLevelOptions" + name="project[project_feature_attributes][infrastructure_access_level]" + /> + </project-setting-row> <project-setting-row ref="releases-settings" :label="$options.i18n.releasesLabel" diff --git a/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js b/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js index 5f08943d211..84ff802c268 100644 --- a/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js +++ b/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js @@ -1,7 +1,15 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { joinPaths, webIDEUrl } from '~/lib/utils/url_utility'; import WebIdeButton from '~/vue_shared/components/web_ide_link.vue'; +import createDefaultClient from '~/lib/graphql'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); export default ({ el, router }) => { if (!el) return; @@ -9,15 +17,18 @@ export default ({ el, router }) => { const { projectPath, ref, isBlob, webIdeUrl, ...options } = convertObjectPropsToCamelCase( JSON.parse(el.dataset.options), ); + const { webIdePromoPopoverImg } = el.dataset; // eslint-disable-next-line no-new new Vue({ el, router, + apolloProvider, render(h) { return h(WebIdeButton, { props: { isBlob, + webIdePromoPopoverImg, webIdeUrl: isBlob ? webIdeUrl : webIDEUrl( diff --git a/app/assets/javascripts/pages/projects/tags/new/index.js b/app/assets/javascripts/pages/projects/tags/new/index.js index 9ef1017f9f2..eb1f705eab9 100644 --- a/app/assets/javascripts/pages/projects/tags/new/index.js +++ b/app/assets/javascripts/pages/projects/tags/new/index.js @@ -1,8 +1,8 @@ import $ from 'jquery'; import GLForm from '~/gl_form'; -import RefSelectDropdown from '~/ref_select_dropdown'; import ZenMode from '~/zen_mode'; +import initNewTagRefSelector from '~/tags/init_new_tag_ref_selector'; +initNewTagRefSelector(); new ZenMode(); // eslint-disable-line no-new new GLForm($('.tag-form')); // eslint-disable-line no-new -new RefSelectDropdown($('.js-branch-select')); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js index 897acf9b02c..eaafc0235a8 100644 --- a/app/assets/javascripts/pages/registrations/new/index.js +++ b/app/assets/javascripts/pages/registrations/new/index.js @@ -4,6 +4,7 @@ import NoEmojiValidator from '~/emoji/no_emoji_validator'; import LengthValidator from '~/pages/sessions/new/length_validator'; import UsernameValidator from '~/pages/sessions/new/username_validator'; import EmailFormatValidator from '~/pages/sessions/new/email_format_validator'; +import { initLanguageSwitcher } from '~/language_switcher'; import Tracking from '~/tracking'; new UsernameValidator(); // eslint-disable-line no-new @@ -19,3 +20,5 @@ trackNewRegistrations(); Tracking.enableFormTracking({ forms: { allow: ['new_user'] }, }); + +initLanguageSwitcher(); diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js index b62417cf595..a84ed5f01ad 100644 --- a/app/assets/javascripts/pages/sessions/new/index.js +++ b/app/assets/javascripts/pages/sessions/new/index.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import initVueAlerts from '~/vue_alerts'; import NoEmojiValidator from '~/emoji/no_emoji_validator'; +import { initLanguageSwitcher } from '~/language_switcher'; import LengthValidator from './length_validator'; import OAuthRememberMe from './oauth_remember_me'; import preserveUrlFragment from './preserve_url_fragment'; @@ -20,3 +21,4 @@ new OAuthRememberMe({ // redirected to sign-in after attempting to access a protected URL that included a fragment. preserveUrlFragment(window.location.hash); initVueAlerts(); +initLanguageSwitcher(); diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue index b72579276e8..b19809aff53 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue @@ -1,10 +1,11 @@ <script> -import { GlSkeletonLoader, GlSafeHtmlDirective, GlAlert } from '@gitlab/ui'; +import { GlSkeletonLoader, GlAlert } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import { handleLocationHash } from '~/lib/utils/common_utils'; -import { renderGFM } from '../render_gfm_facade'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; export default { components: { @@ -12,7 +13,7 @@ export default { GlAlert, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { getWikiContentUrl: { @@ -86,9 +87,9 @@ export default { <div v-else-if="!loadingContentFailed && !isLoadingContent" ref="content" + v-safe-html="content" data-qa-selector="wiki_page_content" data-testid="wiki-page-content" class="js-wiki-page-content md" - v-html="content /* eslint-disable-line vue/no-v-html */" ></div> </template> diff --git a/app/assets/javascripts/pages/shared/wikis/render_gfm_facade.js b/app/assets/javascripts/pages/shared/wikis/render_gfm_facade.js deleted file mode 100644 index 90cc2983153..00000000000 --- a/app/assets/javascripts/pages/shared/wikis/render_gfm_facade.js +++ /dev/null @@ -1,5 +0,0 @@ -import $ from 'jquery'; - -export const renderGFM = (el) => { - return $(el).renderGFM(); -}; diff --git a/app/assets/javascripts/pages/web_ide/remote_ide/index.js b/app/assets/javascripts/pages/web_ide/remote_ide/index.js new file mode 100644 index 00000000000..463798e85b9 --- /dev/null +++ b/app/assets/javascripts/pages/web_ide/remote_ide/index.js @@ -0,0 +1,3 @@ +import { mountRemoteIDE } from '~/ide/remote'; + +mountRemoteIDE(document.getElementById('ide')); diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index 0640faae8b7..ea8005e8dfb 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlDropdown, GlDropdownItem, GlModal, GlModalDirective } from '@gitlab/ui'; +import { GlButton, GlModal, GlModalDirective, GlCollapsibleListbox } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { sortOrders, sortOrderOptions } from '../constants'; @@ -9,9 +9,8 @@ export default { components: { RequestWarning, GlButton, - GlDropdown, - GlDropdownItem, GlModal, + GlCollapsibleListbox, }, directives: { 'gl-modal': GlModalDirective, @@ -119,9 +118,6 @@ export default { itemHasOpenedBacktrace(toggledIndex) { return this.openedBacktraces.find((openedIndex) => openedIndex === toggledIndex) >= 0; }, - changeSortOrder(order) { - this.sortOrder = order; - }, sortDetailByDuration(a, b) { return a.duration < b.duration ? 1 : -1; }, @@ -157,19 +153,14 @@ export default { </div> </div> </div> - <gl-dropdown + <gl-collapsible-listbox v-if="displaySortOrder" - :text="$options.sortOrderOptions[sortOrder]" + v-model="sortOrder" + :toggle-text="$options.sortOrderOptions[sortOrder].text" + :items="Object.values($options.sortOrderOptions)" right data-testid="performance-bar-sort-order" - > - <gl-dropdown-item - v-for="option in Object.keys($options.sortOrderOptions)" - :key="option" - @click="changeSortOrder(option)" - >{{ $options.sortOrderOptions[option] }}</gl-dropdown-item - > - </gl-dropdown> + /> </div> <hr /> <table class="table gl-table"> diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index a5fa85f1ed5..dbca8bc9be7 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -1,5 +1,5 @@ <script> -import { GlSafeHtmlDirective } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { glEmojiTag } from '~/emoji'; import { mergeUrlParams } from '~/lib/utils/url_utility'; @@ -15,7 +15,7 @@ export default { RequestSelector, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { store: { diff --git a/app/assets/javascripts/performance_bar/components/request_warning.vue b/app/assets/javascripts/performance_bar/components/request_warning.vue index 3ebd222029b..91e905d62e6 100644 --- a/app/assets/javascripts/performance_bar/components/request_warning.vue +++ b/app/assets/javascripts/performance_bar/components/request_warning.vue @@ -1,5 +1,6 @@ <script> -import { GlPopover, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlPopover } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { glEmojiTag } from '~/emoji'; export default { @@ -7,7 +8,7 @@ export default { GlPopover, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { htmlId: { diff --git a/app/assets/javascripts/performance_bar/constants.js b/app/assets/javascripts/performance_bar/constants.js index 09745797424..6f4ddd5c242 100644 --- a/app/assets/javascripts/performance_bar/constants.js +++ b/app/assets/javascripts/performance_bar/constants.js @@ -6,6 +6,12 @@ export const sortOrders = { }; export const sortOrderOptions = { - [sortOrders.DURATION]: s__('PerformanceBar|Sort by duration'), - [sortOrders.CHRONOLOGICAL]: s__('PerformanceBar|Sort chronologically'), + [sortOrders.DURATION]: { + value: sortOrders.DURATION, + text: s__('PerformanceBar|Sort by duration'), + }, + [sortOrders.CHRONOLOGICAL]: { + value: sortOrders.CHRONOLOGICAL, + text: s__('PerformanceBar|Sort chronologically'), + }, }; diff --git a/app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue deleted file mode 100644 index cd7cb7f8393..00000000000 --- a/app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue +++ /dev/null @@ -1,490 +0,0 @@ -<script> -import { - GlAlert, - GlIcon, - GlButton, - GlDropdown, - GlDropdownItem, - GlForm, - GlFormGroup, - GlFormInput, - GlFormTextarea, - GlLink, - GlSprintf, - GlLoadingIcon, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; -import { uniqueId } from 'lodash'; -import Vue from 'vue'; -import axios from '~/lib/utils/axios_utils'; -import { backOff } from '~/lib/utils/common_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; -import { redirectTo } from '~/lib/utils/url_utility'; -import { s__, __, n__ } from '~/locale'; -import { - VARIABLE_TYPE, - FILE_TYPE, - CONFIG_VARIABLES_TIMEOUT, - CC_VALIDATION_REQUIRED_ERROR, -} from '../constants'; -import filterVariables from '../utils/filter_variables'; -import RefsDropdown from './refs_dropdown.vue'; - -const i18n = { - variablesDescription: s__( - 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.', - ), - defaultError: __('Something went wrong on our end. Please try again.'), - refsLoadingErrorTitle: s__('Pipeline|Branches or tags could not be loaded.'), - submitErrorTitle: s__('Pipeline|Pipeline cannot be run.'), - warningTitle: __('The form contains the following warning:'), - maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'), - removeVariableLabel: s__('CiVariables|Remove variable'), -}; - -export default { - typeOptions: { - [VARIABLE_TYPE]: __('Variable'), - [FILE_TYPE]: __('File'), - }, - i18n, - formElementClasses: 'gl-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0', - // this height value is used inline on the textarea to match the input field height - // it's used to prevent the overwrite if 'gl-h-7' or 'gl-h-7!' were used - textAreaStyle: { height: '32px' }, - components: { - GlAlert, - GlIcon, - GlButton, - GlDropdown, - GlDropdownItem, - GlForm, - GlFormGroup, - GlFormInput, - GlFormTextarea, - GlLink, - GlSprintf, - GlLoadingIcon, - RefsDropdown, - CcValidationRequiredAlert: () => - import('ee_component/billings/components/cc_validation_required_alert.vue'), - }, - directives: { SafeHtml }, - props: { - pipelinesPath: { - type: String, - required: true, - }, - configVariablesPath: { - type: String, - required: true, - }, - defaultBranch: { - type: String, - required: true, - }, - projectId: { - type: String, - required: true, - }, - settingsLink: { - type: String, - required: true, - }, - fileParams: { - type: Object, - required: false, - default: () => ({}), - }, - refParam: { - type: String, - required: false, - default: '', - }, - variableParams: { - type: Object, - required: false, - default: () => ({}), - }, - maxWarnings: { - type: Number, - required: true, - }, - }, - data() { - return { - refValue: { - shortName: this.refParam, - }, - form: {}, - errorTitle: null, - error: null, - warnings: [], - totalWarnings: 0, - isWarningDismissed: false, - isLoading: false, - submitted: false, - ccAlertDismissed: false, - }; - }, - computed: { - overMaxWarningsLimit() { - return this.totalWarnings > this.maxWarnings; - }, - warningsSummary() { - return n__('%d warning found:', '%d warnings found:', this.warnings.length); - }, - summaryMessage() { - return this.overMaxWarningsLimit ? i18n.maxWarningsSummary : this.warningsSummary; - }, - shouldShowWarning() { - return this.warnings.length > 0 && !this.isWarningDismissed; - }, - refShortName() { - return this.refValue.shortName; - }, - refFullName() { - return this.refValue.fullName; - }, - variables() { - return this.form[this.refFullName]?.variables ?? []; - }, - descriptions() { - return this.form[this.refFullName]?.descriptions ?? {}; - }, - ccRequiredError() { - return this.error === CC_VALIDATION_REQUIRED_ERROR && !this.ccAlertDismissed; - }, - }, - watch: { - refValue() { - this.loadConfigVariablesForm(); - }, - }, - 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.loadConfigVariablesForm(); - }, - methods: { - addEmptyVariable(refValue) { - const { variables } = this.form[refValue]; - - const lastVar = variables[variables.length - 1]; - if (lastVar?.key === '' && lastVar?.value === '') { - return; - } - - variables.push({ - uniqueId: uniqueId(`var-${refValue}`), - variable_type: VARIABLE_TYPE, - key: '', - value: '', - }); - }, - setVariable(refValue, type, key, value) { - const { variables } = this.form[refValue]; - - const variable = variables.find((v) => v.key === key); - if (variable) { - variable.type = type; - variable.value = value; - } else { - variables.push({ - uniqueId: uniqueId(`var-${refValue}`), - key, - value, - variable_type: type, - }); - } - }, - setVariableType(key, type) { - const { variables } = this.form[this.refFullName]; - const variable = variables.find((v) => v.key === key); - variable.variable_type = type; - }, - setVariableParams(refValue, type, paramsObj) { - Object.entries(paramsObj).forEach(([key, value]) => { - this.setVariable(refValue, type, key, value); - }); - }, - removeVariable(index) { - this.variables.splice(index, 1); - }, - canRemove(index) { - return index < this.variables.length - 1; - }, - loadConfigVariablesForm() { - // Skip when variables already cached in `form` - if (this.form[this.refFullName]) { - return; - } - - this.fetchConfigVariables(this.refFullName || this.refShortName) - .then(({ descriptions, params }) => { - Vue.set(this.form, this.refFullName, { - variables: [], - descriptions, - }); - - // Add default variables from yml - this.setVariableParams(this.refFullName, VARIABLE_TYPE, params); - }) - .catch(() => { - Vue.set(this.form, this.refFullName, { - variables: [], - descriptions: {}, - }); - }) - .finally(() => { - // Add/update variables, e.g. from query string - if (this.variableParams) { - this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams); - } - if (this.fileParams) { - this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams); - } - - // Adds empty var at the end of the form - this.addEmptyVariable(this.refFullName); - }); - }, - fetchConfigVariables(refValue) { - this.isLoading = true; - - return backOff((next, stop) => { - axios - .get(this.configVariablesPath, { - params: { - sha: refValue, - }, - }) - .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) { - params[key] = value; - descriptions[key] = description; - } - }); - - return { params, descriptions }; - }) - .catch((error) => { - this.isLoading = false; - - Sentry.captureException(error); - - return { params: {}, descriptions: {} }; - }); - }, - createPipeline() { - this.submitted = true; - this.ccAlertDismissed = false; - - return axios - .post(this.pipelinesPath, { - // send shortName as fall back for query params - // https://gitlab.com/gitlab-org/gitlab/-/issues/287815 - ref: this.refValue.fullName || this.refShortName, - variables_attributes: filterVariables(this.variables), - }) - .then(({ data }) => { - redirectTo(`${this.pipelinesPath}/${data.id}`); - }) - .catch((err) => { - // always re-enable submit button - this.submitted = false; - - const { - errors = [], - warnings = [], - total_warnings: totalWarnings = 0, - } = err.response.data; - const [error] = errors; - - this.reportError({ - title: i18n.submitErrorTitle, - error, - warnings, - totalWarnings, - }); - }); - }, - onRefsLoadingError(error) { - this.reportError({ title: i18n.refsLoadingErrorTitle }); - - Sentry.captureException(error); - }, - reportError({ title = null, error = i18n.defaultError, warnings = [], totalWarnings = 0 }) { - this.errorTitle = title; - this.error = error; - this.warnings = warnings; - this.totalWarnings = totalWarnings; - }, - dismissError() { - this.ccAlertDismissed = true; - this.error = null; - }, - }, -}; -</script> - -<template> - <gl-form @submit.prevent="createPipeline"> - <cc-validation-required-alert v-if="ccRequiredError" class="gl-pb-5" @dismiss="dismissError" /> - <gl-alert - v-else-if="error" - :title="errorTitle" - :dismissible="false" - variant="danger" - class="gl-mb-4" - data-testid="run-pipeline-error-alert" - > - <span v-safe-html="error"></span> - </gl-alert> - <gl-alert - v-if="shouldShowWarning" - :title="$options.i18n.warningTitle" - variant="warning" - class="gl-mb-4" - data-testid="run-pipeline-warning-alert" - @dismiss="isWarningDismissed = true" - > - <details> - <summary> - <gl-sprintf :message="summaryMessage"> - <template #total> - {{ totalWarnings }} - </template> - <template #warningsDisplayed> - {{ maxWarnings }} - </template> - </gl-sprintf> - </summary> - <p - v-for="(warning, index) in warnings" - :key="`warning-${index}`" - data-testid="run-pipeline-warning" - > - {{ warning }} - </p> - </details> - </gl-alert> - <gl-form-group :label="s__('Pipeline|Run for branch name or tag')"> - <refs-dropdown v-model="refValue" @loadingError="onRefsLoadingError" /> - </gl-form-group> - - <gl-loading-icon v-if="isLoading" class="gl-mb-5" size="lg" /> - - <gl-form-group v-else :label="s__('Pipeline|Variables')"> - <div - v-for="(variable, index) in variables" - :key="variable.uniqueId" - class="gl-mb-3 gl-pb-2" - data-testid="ci-variable-row" - data-qa-selector="ci_variable_row_container" - > - <div - class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row" - > - <gl-dropdown - :text="$options.typeOptions[variable.variable_type]" - :class="$options.formElementClasses" - data-testid="pipeline-form-ci-variable-type" - > - <gl-dropdown-item - v-for="type in Object.keys($options.typeOptions)" - :key="type" - @click="setVariableType(variable.key, type)" - > - {{ $options.typeOptions[type] }} - </gl-dropdown-item> - </gl-dropdown> - <gl-form-input - v-model="variable.key" - :placeholder="s__('CiVariables|Input variable key')" - :class="$options.formElementClasses" - data-testid="pipeline-form-ci-variable-key" - data-qa-selector="ci_variable_key_field" - @change="addEmptyVariable(refFullName)" - /> - <gl-form-textarea - v-model="variable.value" - :placeholder="s__('CiVariables|Input variable value')" - class="gl-mb-3" - :style="$options.textAreaStyle" - :no-resize="false" - data-testid="pipeline-form-ci-variable-value" - data-qa-selector="ci_variable_value_field" - /> - - <template v-if="variables.length > 1"> - <gl-button - v-if="canRemove(index)" - class="gl-md-ml-3 gl-mb-3" - data-testid="remove-ci-variable-row" - variant="danger" - category="secondary" - :aria-label="$options.i18n.removeVariableLabel" - @click="removeVariable(index)" - > - <gl-icon class="gl-mr-0! gl-display-none gl-md-display-block" name="clear" /> - <span class="gl-md-display-none">{{ $options.i18n.removeVariableLabel }}</span> - </gl-button> - <gl-button - v-else - class="gl-md-ml-3 gl-mb-3 gl-display-none gl-md-display-block gl-visibility-hidden" - icon="clear" - :aria-label="$options.i18n.removeVariableLabel" - /> - </template> - </div> - <div v-if="descriptions[variable.key]" class="gl-text-gray-500 gl-mb-3"> - {{ descriptions[variable.key] }} - </div> - </div> - - <template #description - ><gl-sprintf :message="$options.i18n.variablesDescription"> - <template #link="{ content }"> - <gl-link :href="settingsLink">{{ content }}</gl-link> - </template> - </gl-sprintf></template - > - </gl-form-group> - <div class="gl-pt-5 gl-display-flex"> - <gl-button - type="submit" - category="primary" - variant="confirm" - class="js-no-auto-disable gl-mr-3" - data-qa-selector="run_pipeline_button" - data-testid="run_pipeline_button" - :disabled="submitted" - >{{ s__('Pipeline|Run pipeline') }}</gl-button - > - <gl-button :href="pipelinesPath">{{ __('Cancel') }}</gl-button> - </div> - </gl-form> -</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 a9af1181027..5692627abef 100644 --- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue @@ -12,11 +12,11 @@ import { GlLink, GlSprintf, GlLoadingIcon, - GlSafeHtmlDirective as SafeHtml, } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { uniqueId } from 'lodash'; import Vue from 'vue'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__, __, n__ } from '~/locale'; import { VARIABLE_TYPE, FILE_TYPE, CC_VALIDATION_REQUIRED_ERROR } from '../constants'; @@ -400,11 +400,13 @@ export default { :class="$options.formElementClasses" class="gl-flex-grow-1 gl-mr-0!" data-testid="pipeline-form-ci-variable-value-dropdown" + data-qa-selector="ci_variable_value_dropdown" > <gl-dropdown-item v-for="value in predefinedValueOptions[variable.key]" :key="value" data-testid="pipeline-form-ci-variable-value-dropdown-items" + data-qa-selector="ci_variable_value_dropdown_item" @click="setVariableAttribute(variable.key, 'value', value)" > {{ value }} diff --git a/app/assets/javascripts/pipeline_new/index.js b/app/assets/javascripts/pipeline_new/index.js index 60b4c93d1d5..71c76aeab36 100644 --- a/app/assets/javascripts/pipeline_new/index.js +++ b/app/assets/javascripts/pipeline_new/index.js @@ -1,53 +1,9 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import LegacyPipelineNewForm from './components/legacy_pipeline_new_form.vue'; import PipelineNewForm from './components/pipeline_new_form.vue'; import { resolvers } from './graphql/resolvers'; -const mountLegacyPipelineNewForm = (el) => { - const { - // provide/inject - projectRefsEndpoint, - - // props - configVariablesPath, - defaultBranch, - fileParam, - maxWarnings, - pipelinesPath, - projectId, - refParam, - settingsLink, - varParam, - } = el.dataset; - - const variableParams = JSON.parse(varParam); - const fileParams = JSON.parse(fileParam); - - return new Vue({ - el, - provide: { - projectRefsEndpoint, - }, - render(createElement) { - return createElement(LegacyPipelineNewForm, { - props: { - configVariablesPath, - defaultBranch, - fileParams, - maxWarnings: Number(maxWarnings), - pipelinesPath, - projectId, - refParam, - settingsLink, - variableParams, - }, - }); - }, - }); -}; - const mountPipelineNewForm = (el) => { const { // provide/inject @@ -101,9 +57,5 @@ const mountPipelineNewForm = (el) => { export default () => { const el = document.getElementById('js-new-pipeline'); - if (gon.features?.runPipelineGraphql) { - mountPipelineNewForm(el); - } else { - mountLegacyPipelineNewForm(el); - } + mountPipelineNewForm(el); }; diff --git a/app/assets/javascripts/pipeline_wizard/components/step_nav.vue b/app/assets/javascripts/pipeline_wizard/components/step_nav.vue index 8f9198855c6..e3d825bbcc7 100644 --- a/app/assets/javascripts/pipeline_wizard/components/step_nav.vue +++ b/app/assets/javascripts/pipeline_wizard/components/step_nav.vue @@ -27,12 +27,13 @@ export default { </script> <template> - <div> + <div class="gl-display-flex"> <slot name="before"></slot> <gl-button v-if="showBackButton" category="secondary" data-testid="back-button" + class="gl-mr-3" @click="$emit('back')" > {{ __('Back') }} diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue index f822e2c0874..4d7596e6e16 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -148,12 +148,13 @@ export default { reportMessageToSentry( this.$options.name, - `| type: ${LOAD_FAILURE} , info: ${serializeLoadErrors(err)}`, + `| type: ${LOAD_FAILURE} , info: ${JSON.stringify(err)}`, { + graphViewType: this.graphViewType, + graphqlResourceEtag: this.graphqlResourceEtag, + metricsPath: this.metricsPath, projectPath: this.pipelineProjectPath, pipelineIid: this.pipelineIid, - pipelineStages: this.pipeline?.stages?.length || 0, - nbOfDownstreams: this.pipeline?.downstream?.length || 0, }, ); }, diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 377f21b299f..4f2be27486c 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -252,7 +252,7 @@ export default { @click="jobItemClick" @mouseout="hideTooltips" > - <div class="ci-job-name-component gl-display-flex gl-align-items-center"> + <div class="gl-display-flex gl-align-items-center gl-flex-grow-1"> <ci-icon :size="24" :status="job.status" class="gl-line-height-0" /> <div class="gl-pl-3 gl-pr-3 gl-display-flex gl-flex-direction-column gl-pipeline-job-width"> <div class="gl-text-truncate gl-pr-9 gl-line-height-normal">{{ job.name }}</div> diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue index 18607bfae1c..c56537f4039 100644 --- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue +++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue @@ -1,5 +1,6 @@ <script> -import { GlButton, GlLink, GlSafeHtmlDirective, GlTableLite } from '@gitlab/ui'; +import { GlButton, GlLink, GlTableLite } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __, s__ } from '~/locale'; import { createAlert } from '~/flash'; import { redirectTo } from '~/lib/utils/url_utility'; @@ -17,7 +18,7 @@ export default { GlTableLite, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { failedJobs: { diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue index 7ee5ec48f44..387b01aee7e 100644 --- a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue +++ b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue @@ -70,7 +70,6 @@ export default { axios .post(`${this.link}.json`) .then(() => { - this.isDisabled = false; this.isLoading = false; this.$emit('pipelineActionRequestComplete'); diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue index f4fc6893520..1c7f5a7476d 100644 --- a/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue +++ b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue @@ -29,7 +29,7 @@ export default { }; </script> <template> - <span class="ci-job-name-component mw-100 gl-display-flex gl-align-items-center"> + <span class="mw-100 gl-display-flex gl-align-items-center gl-flex-grow-1"> <ci-icon :size="iconSize" :status="status" class="gl-line-height-0" /> <span class="gl-text-truncate mw-70p gl-pl-3 gl-display-inline-block"> {{ name }} diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue index 211c5f117c7..51b46f25048 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue @@ -137,9 +137,6 @@ export default { hideTooltips() { this.$root.$emit(BV_HIDE_TOOLTIP); }, - pipelineActionRequestComplete() { - this.$emit('pipelineActionRequestComplete'); - }, }, }; </script> @@ -163,7 +160,7 @@ export default { @click.stop="hideTooltips" @mouseout="hideTooltips" > - <job-name-component :name="job.name" :status="job.status" :icon-size="24" /> + <job-name-component :name="job.name" :status="job.status" /> </gl-link> <div @@ -175,7 +172,7 @@ export default { data-testid="job-without-link" @mouseout="hideTooltips" > - <job-name-component :name="job.name" :status="job.status" :icon-size="24" /> + <job-name-component :name="job.name" :status="job.status" /> </div> <action-component @@ -184,7 +181,6 @@ export default { :link="status.action.path" :action-icon="status.action.icon" data-qa-selector="action_button" - @pipelineActionRequestComplete="pipelineActionRequestComplete" /> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue index 993fa121d89..827adf9f7f7 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue @@ -35,11 +35,6 @@ export default { required: true, default: () => [], }, - stagesClass: { - type: [Array, Object, String], - required: false, - default: '', - }, updateDropdown: { type: Boolean, required: false, @@ -56,15 +51,10 @@ export default { return Boolean(this.downstreamPipelines.length); }, }, - methods: { - onPipelineActionRequestComplete() { - this.$emit('pipelineActionRequestComplete'); - }, - }, }; </script> <template> - <div class="stage-cell" data-testid="pipeline-mini-graph"> + <div data-testid="pipeline-mini-graph"> <linked-pipelines-mini-list v-if="upstreamPipeline" :triggered-by="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ @@ -82,9 +72,7 @@ export default { :is-merge-train="isMergeTrain" :stages="stages" :update-dropdown="updateDropdown" - :stages-class="stagesClass" data-testid="pipeline-stages" - @pipelineActionRequestComplete="onPipelineActionRequestComplete" @miniGraphStageClick="$emit('miniGraphStageClick')" /> <gl-icon diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue index ba150919e58..ec42b738e03 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue @@ -100,13 +100,6 @@ export default { }); }); }, - pipelineActionRequestComplete() { - // close the dropdown in MR widget - this.$refs.dropdown.hide(); - - // warn the pipelines table to update - this.$emit('pipelineActionRequestComplete'); - }, stageAriaLabel(title) { return sprintf(__('View Stage: %{title}'), { title }); }, @@ -149,7 +142,7 @@ export default { class="js-builds-dropdown-list scrollable-menu" data-testid="mini-pipeline-graph-dropdown-menu-list" > - <div class="gl--flex-center gl-border-b gl-font-weight-bold gl-pb-3"> + <div class="gl--flex-center gl-border-b gl-font-weight-bold gl-mb-3 gl-pb-3"> <span class="gl-mr-1">{{ $options.i18n.stage }}</span> <span data-testid="pipeline-stage-dropdown-menu-title">{{ stageName }}</span> </div> @@ -158,11 +151,10 @@ export default { :dropdown-length="dropdownContent.length" :job="job" css-class-job-name="mini-pipeline-graph-dropdown-item" - @pipelineActionRequestComplete="pipelineActionRequestComplete" /> </li> <template v-if="isMergeTrain"> - <li class="gl-new-dropdown-divider" role="presentation"> + <li class="gl-dropdown-divider" role="presentation"> <hr role="separator" aria-orientation="horizontal" class="dropdown-divider" /> </li> <li> diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue index e965dc5e6b0..ba549d9b423 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue @@ -17,22 +17,12 @@ export default { required: false, default: false, }, - stagesClass: { - type: [Array, Object, String], - required: false, - default: '', - }, isMergeTrain: { type: Boolean, required: false, default: false, }, }, - methods: { - onPipelineActionRequestComplete() { - this.$emit('pipelineActionRequestComplete'); - }, - }, }; </script> <template> @@ -40,14 +30,12 @@ export default { <div v-for="stage in stages" :key="stage.name" - :class="stagesClass" - class="dropdown gl-display-inline-block gl-mr-2 gl-my-2 gl-vertical-align-middle stage-container" + class="pipeline-mini-graph-stage-container dropdown gl-display-inline-block gl-mr-2 gl-my-2 gl-vertical-align-middle" > <pipeline-stage :stage="stage" :update-dropdown="updateDropdown" :is-merge-train="isMergeTrain" - @pipelineActionRequestComplete="onPipelineActionRequestComplete" @miniGraphStageClick="$emit('miniGraphStageClick')" /> </div> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue index 3eafb36bd1d..03a2eac89e4 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue @@ -8,7 +8,7 @@ import { RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT, RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT, I18N, -} from '~/pipeline_editor/constants'; +} from '~/ci/pipeline_editor/constants'; import Tracking from '~/tracking'; import { helpPagePath } from '~/helpers/help_page_helper'; import { isExperimentVariant } from '~/experimentation/utils'; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue index af089aebbbe..7dc1e60610e 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue @@ -3,7 +3,7 @@ import { GlFilteredSearch } from '@gitlab/ui'; import { map } from 'lodash'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; import { TRACKING_CATEGORIES } from '../../constants'; import PipelineBranchNameToken from './tokens/pipeline_branch_name_token.vue'; import PipelineSourceToken from './tokens/pipeline_source_token.vue'; @@ -54,7 +54,7 @@ export default { title: s__('Pipeline|Trigger author'), unique: true, token: PipelineTriggerAuthorToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, projectId: this.projectId, }, { @@ -63,7 +63,7 @@ export default { title: s__('Pipeline|Branch name'), unique: true, token: PipelineBranchNameToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, projectId: this.projectId, defaultBranchName: this.defaultBranchName, disabled: this.selectedTypes.includes(this.$options.tagType), @@ -74,7 +74,7 @@ export default { title: s__('Pipeline|Tag name'), unique: true, token: PipelineTagNameToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, projectId: this.projectId, disabled: this.selectedTypes.includes(this.$options.branchType), }, @@ -84,7 +84,7 @@ export default { title: s__('Pipeline|Status'), unique: true, token: PipelineStatusToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }, { type: this.$options.sourceType, @@ -92,7 +92,7 @@ export default { title: s__('Pipeline|Source'), unique: true, token: PipelineSourceToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }, ]; }, diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue index f6e46c090d3..346f5735576 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -124,9 +124,6 @@ export default { eventHub.$emit('postAction', this.endpoint); this.cancelingPipeline = this.pipelineId; }, - onPipelineActionRequestComplete() { - eventHub.$emit('refreshPipelinesTable'); - }, trackPipelineMiniGraph() { this.track('click_minigraph', { label: TRACKING_CATEGORIES.table }); }, @@ -179,7 +176,6 @@ export default { :stages="item.details.stages" :update-dropdown="updateGraphDropdown" :upstream-pipeline="item.triggered_by" - @pipelineActionRequestComplete="onPipelineActionRequestComplete" @miniGraphStageClick="trackPipelineMiniGraph" /> </template> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue index e5666f7a658..3f2c013d44a 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue @@ -1,8 +1,6 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import createTestReportsStore from '../../stores/test_reports'; import EmptyState from './empty_state.vue'; import TestSuiteTable from './test_suite_table.vue'; import TestSummary from './test_summary.vue'; @@ -17,7 +15,6 @@ export default { TestSummary, TestSummaryTable, }, - mixins: [glFeatureFlagMixin()], inject: ['blobPath', 'summaryEndpoint', 'suiteEndpoint'], computed: { ...mapState('testReports', ['isLoading', 'selectedSuiteIndex', 'testReports']), @@ -31,17 +28,6 @@ export default { }, }, created() { - if (!this.glFeatures.pipelineTabsVue) { - this.$store.registerModule( - 'testReports', - createTestReportsStore({ - blobPath: this.blobPath, - summaryEndpoint: this.summaryEndpoint, - suiteEndpoint: this.suiteEndpoint, - }), - ); - } - this.fetchSummary(); }, methods: { diff --git a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js index 9602ca1ba88..07551c2342f 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js @@ -55,7 +55,6 @@ export default { eventHub.$on('retryPipeline', this.postAction); eventHub.$on('clickedDropdown', this.updateTable); eventHub.$on('updateTable', this.updateTable); - eventHub.$on('refreshPipelinesTable', this.fetchPipelines); eventHub.$on('runMergeRequestPipeline', this.runMergeRequestPipeline); }, beforeDestroy() { @@ -63,7 +62,6 @@ export default { eventHub.$off('retryPipeline', this.postAction); eventHub.$off('clickedDropdown', this.updateTable); eventHub.$off('updateTable', this.updateTable); - eventHub.$off('refreshPipelinesTable', this.fetchPipelines); eventHub.$off('runMergeRequestPipeline', this.runMergeRequestPipeline); }, destroyed() { diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 1bbdd3625be..f00378733fc 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -1,36 +1,33 @@ import VueRouter from 'vue-router'; import { createAlert } from '~/flash'; -import { __, s__ } from '~/locale'; -import createDagApp from './pipeline_details_dag'; -import { createPipelinesDetailApp } from './pipeline_details_graph'; +import { __ } from '~/locale'; import { createPipelineHeaderApp } from './pipeline_details_header'; -import { createPipelineJobsApp } from './pipeline_details_jobs'; -import { createPipelineFailedJobsApp } from './pipeline_details_failed_jobs'; import { apolloProvider } from './pipeline_shared_client'; -import { createTestDetails } from './pipeline_test_details'; const SELECTORS = { - PIPELINE_DETAILS: '.js-pipeline-details-vue', - PIPELINE_GRAPH: '#js-pipeline-graph-vue', PIPELINE_HEADER: '#js-pipeline-header-vue', PIPELINE_TABS: '#js-pipeline-tabs', - PIPELINE_TESTS: '#js-pipeline-tests-detail', - PIPELINE_JOBS: '#js-pipeline-jobs-vue', - PIPELINE_FAILED_JOBS: '#js-pipeline-failed-jobs-vue', }; export default async function initPipelineDetailsBundle() { - const { dataset } = document.querySelector(SELECTORS.PIPELINE_DETAILS); + const { dataset: headerDataset } = document.querySelector(SELECTORS.PIPELINE_HEADER); try { - createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER, apolloProvider, dataset.graphqlResourceEtag); + createPipelineHeaderApp( + SELECTORS.PIPELINE_HEADER, + apolloProvider, + headerDataset.graphqlResourceEtag, + ); } catch { createAlert({ message: __('An error occurred while loading a section of this page.'), }); } - if (gon.features?.pipelineTabsVue) { + const tabsEl = document.querySelector(SELECTORS.PIPELINE_TABS); + + if (tabsEl) { + const { dataset } = tabsEl; const { createAppOptions } = await import('ee_else_ce/pipelines/pipeline_tabs'); const { createPipelineTabs } = await import('./pipeline_tabs'); const { routes } = await import('ee_else_ce/pipelines/routes'); @@ -49,45 +46,5 @@ export default async function initPipelineDetailsBundle() { message: __('An error occurred while loading a section of this page.'), }); } - } else { - try { - createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset); - } catch { - createAlert({ - message: __('An error occurred while loading the pipeline.'), - }); - } - - try { - createDagApp(apolloProvider); - } catch { - createAlert({ - message: __('An error occurred while loading the Needs tab.'), - }); - } - - try { - createTestDetails(SELECTORS.PIPELINE_TESTS); - } catch { - createAlert({ - message: __('An error occurred while loading the Test Reports tab.'), - }); - } - - try { - createPipelineJobsApp(SELECTORS.PIPELINE_JOBS); - } catch { - createAlert({ - message: __('An error occurred while loading the Jobs tab.'), - }); - } - - try { - createPipelineFailedJobsApp(SELECTORS.PIPELINE_FAILED_JOBS); - } catch { - createAlert({ - message: s__('Jobs|An error occurred while loading the Failed Jobs tab.'), - }); - } } } diff --git a/app/assets/javascripts/pipelines/pipeline_details_dag.js b/app/assets/javascripts/pipelines/pipeline_details_dag.js deleted file mode 100644 index b2cb0457c4d..00000000000 --- a/app/assets/javascripts/pipelines/pipeline_details_dag.js +++ /dev/null @@ -1,42 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import Dag from './components/dag/dag.vue'; - -Vue.use(VueApollo); - -const createDagApp = (apolloProvider) => { - const el = document.querySelector('#js-pipeline-dag-vue'); - - if (!el) { - return; - } - - const { - aboutDagDocPath, - dagDocPath, - emptySvgPath, - pipelineProjectPath, - pipelineIid, - } = el.dataset; - - // eslint-disable-next-line no-new - new Vue({ - el, - components: { - Dag, - }, - apolloProvider, - provide: { - aboutDagDocPath, - dagDocPath, - emptySvgPath, - pipelineProjectPath, - pipelineIid, - }, - render(createElement) { - return createElement('dag', {}); - }, - }); -}; - -export default createDagApp; diff --git a/app/assets/javascripts/pipelines/pipeline_details_failed_jobs.js b/app/assets/javascripts/pipelines/pipeline_details_failed_jobs.js deleted file mode 100644 index 7bf3b64bf47..00000000000 --- a/app/assets/javascripts/pipelines/pipeline_details_failed_jobs.js +++ /dev/null @@ -1,36 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; -import FailedJobsApp from './components/jobs/failed_jobs_app.vue'; - -Vue.use(VueApollo); - -const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), -}); - -export const createPipelineFailedJobsApp = (selector) => { - const containerEl = document.querySelector(selector); - - if (!containerEl) { - return false; - } - - const { fullPath, pipelineIid, failedJobsSummaryData } = containerEl.dataset; - - return new Vue({ - el: containerEl, - apolloProvider, - provide: { - fullPath, - pipelineIid, - }, - render(createElement) { - return createElement(FailedJobsApp, { - props: { - failedJobsSummary: JSON.parse(failedJobsSummaryData), - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/pipelines/pipeline_details_graph.js b/app/assets/javascripts/pipelines/pipeline_details_graph.js deleted file mode 100644 index 9dd5cd7b281..00000000000 --- a/app/assets/javascripts/pipelines/pipeline_details_graph.js +++ /dev/null @@ -1,35 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import PipelineGraphWrapper from './components/graph/graph_component_wrapper.vue'; -import { reportToSentry } from './utils'; - -Vue.use(VueApollo); - -const createPipelinesDetailApp = ( - selector, - apolloProvider, - { pipelineProjectPath, pipelineIid, metricsPath, graphqlResourceEtag } = {}, -) => { - // eslint-disable-next-line no-new - new Vue({ - el: selector, - components: { - PipelineGraphWrapper, - }, - apolloProvider, - provide: { - metricsPath, - pipelineProjectPath, - pipelineIid, - graphqlResourceEtag, - }, - errorCaptured(err, _vm, info) { - reportToSentry('pipeline_details_graph', `error: ${err}, info: ${info}`); - }, - render(createElement) { - return createElement(PipelineGraphWrapper); - }, - }); -}; - -export { createPipelinesDetailApp }; diff --git a/app/assets/javascripts/pipelines/pipeline_details_jobs.js b/app/assets/javascripts/pipelines/pipeline_details_jobs.js deleted file mode 100644 index a1294a484f0..00000000000 --- a/app/assets/javascripts/pipelines/pipeline_details_jobs.js +++ /dev/null @@ -1,34 +0,0 @@ -import { GlToast } from '@gitlab/ui'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; -import JobsApp from './components/jobs/jobs_app.vue'; - -Vue.use(VueApollo); -Vue.use(GlToast); - -const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), -}); - -export const createPipelineJobsApp = (selector) => { - const containerEl = document.querySelector(selector); - - if (!containerEl) { - return false; - } - - const { fullPath, pipelineIid } = containerEl.dataset; - - return new Vue({ - el: containerEl, - apolloProvider, - provide: { - fullPath, - pipelineIid, - }, - render(createElement) { - return createElement(JobsApp); - }, - }); -}; diff --git a/app/assets/javascripts/pipelines/pipeline_test_details.js b/app/assets/javascripts/pipelines/pipeline_test_details.js deleted file mode 100644 index fe4ca8e9529..00000000000 --- a/app/assets/javascripts/pipelines/pipeline_test_details.js +++ /dev/null @@ -1,40 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import Translate from '~/vue_shared/translate'; -import TestReports from './components/test_reports/test_reports.vue'; - -Vue.use(Vuex); -Vue.use(Translate); - -export const createTestDetails = (selector) => { - const el = document.querySelector(selector); - const { - blobPath, - emptyStateImagePath, - hasTestReport, - summaryEndpoint, - suiteEndpoint, - artifactsExpiredImagePath, - } = el?.dataset || {}; - - // eslint-disable-next-line no-new - new Vue({ - el, - components: { - TestReports, - }, - provide: { - emptyStateImagePath, - artifactsExpiredImagePath, - hasTestReport: parseBoolean(hasTestReport), - blobPath, - summaryEndpoint, - suiteEndpoint, - }, - store: new Vuex.Store(), - render(createElement) { - return createElement('test-reports'); - }, - }); -}; diff --git a/app/assets/javascripts/popovers/components/popovers.vue b/app/assets/javascripts/popovers/components/popovers.vue index a758503b56b..7ec54231e65 100644 --- a/app/assets/javascripts/popovers/components/popovers.vue +++ b/app/assets/javascripts/popovers/components/popovers.vue @@ -1,5 +1,6 @@ <script> -import { GlPopover, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlPopover } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; const newPopover = (element) => { const { content, html, placement, title, triggers = 'focus' } = element.dataset; @@ -19,7 +20,7 @@ export default { GlPopover, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, data() { return { diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue index b038b78088f..51e62984715 100644 --- a/app/assets/javascripts/profile/account/components/update_username.vue +++ b/app/assets/javascripts/profile/account/components/update_username.vue @@ -1,6 +1,7 @@ <script> -import { GlSafeHtmlDirective as SafeHtml, GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; +import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; import { escape } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { createAlert, VARIANT_INFO } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __, s__, sprintf } from '~/locale'; diff --git a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue index 52da8aaba4d..a037e721677 100644 --- a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue +++ b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue @@ -28,6 +28,11 @@ export default { required: false, default: '', }, + blanked: { + type: Boolean, + required: false, + default: false, + }, }, i18n: { noResultsMessage: I18N_NO_RESULTS_MESSAGE, @@ -36,7 +41,7 @@ export default { }, data() { return { - searchTerm: this.value, + searchTerm: this.blanked ? '' : this.value, }; }, computed: { diff --git a/app/assets/javascripts/projects/commit/components/form_modal.vue b/app/assets/javascripts/projects/commit/components/form_modal.vue index d9aaa574fec..1febe8ceaab 100644 --- a/app/assets/javascripts/projects/commit/components/form_modal.vue +++ b/app/assets/javascripts/projects/commit/components/form_modal.vue @@ -41,6 +41,11 @@ export default { required: false, default: false, }, + isRevert: { + type: Boolean, + required: false, + default: false, + }, primaryActionEventName: { type: String, required: false, @@ -150,7 +155,12 @@ export default { > <input id="start_branch" type="hidden" name="start_branch" :value="branch" /> - <branches-dropdown class="gl-w-half" :value="branch" @selectBranch="setBranch" /> + <branches-dropdown + class="gl-w-half" + :value="branch" + :blanked="isRevert" + @selectBranch="setBranch" + /> </gl-form-group> <gl-form-checkbox diff --git a/app/assets/javascripts/projects/commit/init_revert_commit_modal.js b/app/assets/javascripts/projects/commit/init_revert_commit_modal.js index 849b2f4858c..41be71932e5 100644 --- a/app/assets/javascripts/projects/commit/init_revert_commit_modal.js +++ b/app/assets/javascripts/projects/commit/init_revert_commit_modal.js @@ -49,6 +49,7 @@ export default function initInviteMembersModal(primaryActionEventName) { i18n: { ...I18N_REVERT_MODAL, ...I18N_MODAL }, openModal: OPEN_REVERT_MODAL, modalId: REVERT_MODAL_ID, + isRevert: true, primaryActionEventName, }, }), diff --git a/app/assets/javascripts/projects/commits/index.js b/app/assets/javascripts/projects/commits/index.js index 03b94fde0f3..53169f689c9 100644 --- a/app/assets/javascripts/projects/commits/index.js +++ b/app/assets/javascripts/projects/commits/index.js @@ -1,11 +1,13 @@ import Vue from 'vue'; import Vuex from 'vuex'; +import { visitUrl } from '~/lib/utils/url_utility'; +import RefSelector from '~/ref/components/ref_selector.vue'; import AuthorSelectApp from './components/author_select.vue'; import store from './store'; Vue.use(Vuex); -export default (el) => { +export const mountCommits = (el) => { if (!el) { return null; } @@ -24,3 +26,30 @@ export default (el) => { }, }); }; + +export const initCommitsRefSwitcher = () => { + const el = document.getElementById('js-project-commits-ref-switcher'); + const COMMITS_PATH_REGEX = /^(.*?)\/-\/commits/g; + + if (!el) return false; + + const { projectId, ref, commitsPath } = el.dataset; + const commitsPathPrefix = commitsPath.match(COMMITS_PATH_REGEX)?.[0]; + + return new Vue({ + el, + render(createElement) { + return createElement(RefSelector, { + props: { + projectId, + value: ref, + }, + on: { + input(selected) { + visitUrl(`${commitsPathPrefix}/${selected}`); + }, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue index ba1e00a2b36..c00e75db722 100644 --- a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue +++ b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue @@ -57,7 +57,7 @@ export default { <gl-dropdown :text="selectedProject.name" :header-text="s__(`CompareRevisions|Select target project`)" - class="gl-w-full gl-font-monospace gl-sm-pr-3" + class="gl-w-full gl-font-monospace" toggle-class="gl-min-w-0" :disabled="disableRepoDropdown" > diff --git a/app/assets/javascripts/projects/compare/components/revision_card.vue b/app/assets/javascripts/projects/compare/components/revision_card.vue index d6ada24604d..162aca44f9d 100644 --- a/app/assets/javascripts/projects/compare/components/revision_card.vue +++ b/app/assets/javascripts/projects/compare/components/revision_card.vue @@ -43,7 +43,7 @@ export default { <h2 class="gl-font-size-h2"> {{ s__(`CompareRevisions|${revisionText}`) }} </h2> - <div class="gl-sm-display-flex gl-align-items-center"> + <div class="gl-sm-display-flex gl-align-items-center gl-gap-3"> <repo-dropdown class="gl-sm-w-half" :params-name="paramsName" diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js index 3671b24b502..a44855c14d5 100644 --- a/app/assets/javascripts/projects/default_project_templates.js +++ b/app/assets/javascripts/projects/default_project_templates.js @@ -113,4 +113,12 @@ export default { text: s__('ProjectTemplates|Jsonnet for Dynamic Child Pipelines'), icon: '.template-option .icon-gitlab_logo', }, + bridgetown: { + text: s__('ProjectTemplates|Pages/Bridgetown'), + icon: '.template-option .icon-gitlab_logo', + }, + typo3_distribution: { + text: s__('ProjectTemplates|TYPO3 Distribution'), + icon: '.template-option .icon-typo3', + }, }; diff --git a/app/assets/javascripts/projects/new/components/app.vue b/app/assets/javascripts/projects/new/components/app.vue index 59ca393fe92..3100029eb31 100644 --- a/app/assets/javascripts/projects/new/components/app.vue +++ b/app/assets/javascripts/projects/new/components/app.vue @@ -3,7 +3,7 @@ import createFromTemplateIllustration from '@gitlab/svgs/dist/illustrations/proj import blankProjectIllustration from '@gitlab/svgs/dist/illustrations/project-create-new-sm.svg'; import importProjectIllustration from '@gitlab/svgs/dist/illustrations/project-import-sm.svg'; import ciCdProjectIllustration from '@gitlab/svgs/dist/illustrations/project-run-CICD-pipelines-sm.svg'; -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__ } from '~/locale'; import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue'; import NewProjectPushTipPopover from './new_project_push_tip_popover.vue'; diff --git a/app/assets/javascripts/projects/new/components/new_project_url_select.vue b/app/assets/javascripts/projects/new/components/new_project_url_select.vue index eccfb3d844c..d6d88b5b297 100644 --- a/app/assets/javascripts/projects/new/components/new_project_url_select.vue +++ b/app/assets/javascripts/projects/new/components/new_project_url_select.vue @@ -46,7 +46,15 @@ export default { debounce: DEBOUNCE_DELAY, }, }, - inject: ['namespaceFullPath', 'namespaceId', 'rootUrl', 'trackLabel', 'userNamespaceId'], + inject: [ + 'namespaceFullPath', + 'namespaceId', + 'rootUrl', + 'trackLabel', + 'userNamespaceId', + 'inputName', + 'inputId', + ], data() { return { currentUser: {}, @@ -124,6 +132,11 @@ export default { } : this.$options.emptyNameSpace; }, + trackDropdownShow() { + if (this.trackLabel) { + this.track('activate_form_input', { label: this.trackLabel, property: 'project_path' }); + } + }, }, emptyNameSpace: { id: undefined, @@ -145,7 +158,7 @@ export default { class="js-group-namespace-dropdown gl-flex-grow-1" :toggle-class="`gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20 ${dropdownPlaceholderClass}`" data-qa-selector="select_namespace_dropdown" - @show="track('activate_form_input', { label: trackLabel, property: 'project_path' })" + @show="trackDropdownShow" @shown="handleDropdownShown" > <template #button-text> @@ -173,7 +186,7 @@ export default { {{ group.fullPath }} </gl-dropdown-item> </template> - <template v-if="hasNamespaceMatches"> + <template v-if="hasNamespaceMatches && userNamespaceId"> <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header> <gl-dropdown-item @click="handleDropdownItemClick(userNamespace)"> {{ userNamespace.fullPath }} @@ -186,9 +199,9 @@ export default { <input type="hidden" name="project[selected_namespace_id]" :value="selectedNamespace.id" /> <input - id="project_namespace_id" + :id="inputId" type="hidden" - name="project[namespace_id]" + :name="inputName" :value="selectedNamespace.id || userNamespaceId" /> </gl-button-group> diff --git a/app/assets/javascripts/projects/new/constants.js b/app/assets/javascripts/projects/new/constants.js index e52a84dc07e..7b6b2cfc7ca 100644 --- a/app/assets/javascripts/projects/new/constants.js +++ b/app/assets/javascripts/projects/new/constants.js @@ -12,6 +12,8 @@ export const DEPLOYMENT_TARGET_SELECTIONS = [ s__('DeploymentTarget|Registry (package or container)'), s__('DeploymentTarget|Infrastructure provider (Terraform, Cloudformation, and so on)'), s__('DeploymentTarget|Serverless backend (Lambda, Cloud functions)'), + s__('DeploymentTarget|Edge Computing (e.g. Cloudflare Workers)'), + s__('DeploymentTarget|Web Deployment Platform (Netlify, Vercel, Gatsby)'), s__('DeploymentTarget|GitLab Pages'), s__('DeploymentTarget|Other hosting service'), s__('DeploymentTarget|No deployment planned'), diff --git a/app/assets/javascripts/projects/new/index.js b/app/assets/javascripts/projects/new/index.js index a72172a4f5e..910244c657b 100644 --- a/app/assets/javascripts/projects/new/index.js +++ b/app/assets/javascripts/projects/new/index.js @@ -59,6 +59,8 @@ export function initNewProjectUrlSelect() { rootUrl: el.dataset.rootUrl, trackLabel: el.dataset.trackLabel, userNamespaceId: el.dataset.userNamespaceId, + inputId: el.dataset.inputId, + inputName: el.dataset.inputName, }, render: (createElement) => createElement(NewProjectUrlSelect), }), diff --git a/app/assets/javascripts/projects/project_name_rules.js b/app/assets/javascripts/projects/project_name_rules.js new file mode 100644 index 00000000000..eeef1fb5afc --- /dev/null +++ b/app/assets/javascripts/projects/project_name_rules.js @@ -0,0 +1,28 @@ +import { __ } from '~/locale'; + +const rulesReg = [ + { + reg: /^[a-zA-Z0-9\u{00A9}-\u{1f9ff}_]/u, + msg: __("Name must start with a letter, digit, emoji, or '_'"), + }, + { + reg: /^[a-zA-Z0-9\p{Pd}\u{002B}\u{00A9}-\u{1f9ff}_. ]+$/u, + msg: __("Name can contain only letters, digits, emojis, '_', '.', '+', dashes, or spaces"), + }, +]; + +/** + * + * @param {string} text + * @returns {string} msg + */ +function checkRules(text) { + for (const item of rulesReg) { + if (!item.reg.test(text)) { + return item.msg; + } + } + return ''; +} + +export { checkRules }; diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 424ea3b61c5..d71e80dffcf 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -12,6 +12,7 @@ import { slugify, convertUnicodeToAscii, } from '../lib/utils/text_utility'; +import { checkRules } from './project_name_rules'; let hasUserDefinedProjectPath = false; let hasUserDefinedProjectName = false; @@ -87,10 +88,23 @@ const validateGroupNamespaceDropdown = (e) => { } }; +const checkProjectName = (projectNameInput) => { + const msg = checkRules(projectNameInput.value); + const projectNameError = document.querySelector('#project_name_error'); + if (!projectNameError) return; + if (msg) { + projectNameError.innerText = msg; + projectNameError.classList.remove('hidden'); + } else { + projectNameError.classList.add('hidden'); + } +}; + const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => { const specialRepo = document.querySelector('.js-user-readme-repo'); const projectNameInputListener = () => { onProjectNameChange($projectNameInput, $projectPathInput); + checkProjectName($projectNameInput); hasUserDefinedProjectName = $projectNameInput.value.trim().length > 0; hasUserDefinedProjectPath = $projectPathInput.value.trim().length > 0; }; diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js index 335545c802a..dcf7415a444 100644 --- a/app/assets/javascripts/projects/settings/access_dropdown.js +++ b/app/assets/javascripts/projects/settings/access_dropdown.js @@ -580,7 +580,7 @@ export default class AccessDropdown { return ` <li> <a href="#" class="${isActiveClass} item-${role.type}" data-role-id="${role.id}"> - ${role.text} + ${escape(role.text)} </a> </li> `; diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js index 6da058ebc9c..61c37a2348a 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js @@ -6,6 +6,7 @@ export const I18N = { branchNameOrPattern: s__('BranchRules|Branch name or pattern'), branch: s__('BranchRules|Target Branch'), allBranches: s__('BranchRules|All branches'), + matchingBranchesLinkTitle: s__('BranchRules|%{total} matching %{subject}'), protectBranchTitle: s__('BranchRules|Protect branch'), protectBranchDescription: s__( 'BranchRules|Keep stable branches secure and force developers to use merge requests. %{linkStart}What are protected branches?%{linkEnd}', diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue index eb11e17dd1b..626ed67c466 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue @@ -1,9 +1,10 @@ <script> import { GlSprintf, GlLink, GlLoadingIcon } from '@gitlab/ui'; -import { sprintf } from '~/locale'; -import { getParameterByName } from '~/lib/utils/url_utility'; +import { sprintf, n__ } from '~/locale'; +import { getParameterByName, mergeUrlParams } from '~/lib/utils/url_utility'; import { helpPagePath } from '~/helpers/help_page_helper'; import branchRulesQuery from '../../queries/branch_rules_details.query.graphql'; +import { getAccessLevels } from '../../../utils'; import Protection from './protection.vue'; import { I18N, @@ -41,6 +42,9 @@ export default { statusChecksPath: { default: '', }, + branchesPath: { + default: '', + }, }, apollo: { project: { @@ -55,6 +59,7 @@ export default { this.branchProtection = branchRule?.branchProtection; this.approvalRules = branchRule?.approvalRules; this.statusChecks = branchRule?.externalStatusChecks?.nodes || []; + this.matchingBranchesCount = branchRule?.matchingBranchesCount; }, }, }, @@ -64,6 +69,7 @@ export default { branchProtection: {}, approvalRules: {}, statusChecks: [], + matchingBranchesCount: null, }; }, computed: { @@ -115,28 +121,20 @@ export default { ? this.$options.i18n.targetBranch : this.$options.i18n.branchNameOrPattern; }, + matchingBranchesLinkHref() { + return mergeUrlParams({ state: 'all', search: this.branch }, this.branchesPath); + }, + matchingBranchesLinkTitle() { + const total = this.matchingBranchesCount; + const subject = n__('branch', 'branches', total); + return sprintf(this.$options.i18n.matchingBranchesLinkTitle, { total, subject }); + }, approvals() { return this.approvalRules?.nodes || []; }, }, methods: { - getAccessLevels(accessLevels = {}) { - const total = accessLevels.edges?.length; - const accessLevelTypes = { total, users: [], groups: [], roles: [] }; - - accessLevels.edges?.forEach(({ node }) => { - if (node.user) { - const src = node.user.avatarUrl; - accessLevelTypes.users.push({ src, ...node.user }); - } else if (node.group) { - accessLevelTypes.groups.push(node); - } else { - accessLevelTypes.roles.push(node); - } - }); - - return accessLevelTypes; - }, + getAccessLevels, }, }; </script> @@ -161,6 +159,10 @@ export default { </div> <code v-else class="gl-mt-2" data-testid="branch">{{ branch }}</code> + <p v-if="matchingBranchesCount" class="gl-mt-3"> + <gl-link :href="matchingBranchesLinkHref">{{ matchingBranchesLinkTitle }}</gl-link> + </p> + <h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.protectBranchTitle }}</h4> <gl-sprintf :message="$options.i18n.protectBranchDescription"> <template #link="{ content }"> diff --git a/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js b/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js index 89cfb1e1c8e..7639acc1181 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js +++ b/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js @@ -14,7 +14,13 @@ export default function mountBranchRules(el) { defaultClient: createDefaultClient(), }); - const { projectPath, protectedBranchesPath, approvalRulesPath, statusChecksPath } = el.dataset; + const { + projectPath, + protectedBranchesPath, + approvalRulesPath, + statusChecksPath, + branchesPath, + } = el.dataset; return new Vue({ el, @@ -24,6 +30,7 @@ export default function mountBranchRules(el) { protectedBranchesPath, approvalRulesPath, statusChecksPath, + branchesPath, }, render(h) { return h(View); diff --git a/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql b/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql index aa1e4923aa8..a832e59aa67 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql +++ b/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql @@ -68,6 +68,7 @@ query getBranchRulesDetails($projectPath: ID!) { externalUrl } } + matchingBranchesCount } } } diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue index a9eb2a53fbf..9b669024a8b 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue @@ -1,7 +1,7 @@ <script> import { s__ } from '~/locale'; import { createAlert } from '~/flash'; -import branchRulesQuery from './graphql/queries/branch_rules.query.graphql'; +import branchRulesQuery from 'ee_else_ce/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql'; import BranchRule from './components/branch_rule.vue'; export const i18n = { @@ -51,13 +51,14 @@ export default { <template> <div class="settings-content"> <branch-rule - v-for="rule in branchRules" - :key="rule.name" + v-for="(rule, index) in branchRules" + :key="`${rule.name}-${index}`" :name="rule.name" :is-default="rule.isDefault" :branch-protection="rule.branchProtection" - :status-checks-total="rule.externalStatusChecks.nodes.length" - :approval-rules-total="rule.approvalRules.nodes.length" + :status-checks-total="rule.externalStatusChecks ? rule.externalStatusChecks.nodes.length : 0" + :approval-rules-total="rule.approvalRules ? rule.approvalRules.nodes.length : 0" + :matching-branches-count="rule.matchingBranchesCount" /> <span v-if="!branchRules.length" data-testid="empty">{{ $options.i18n.emptyState }}</span> diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue index 78c824c66d1..41947834bdb 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue @@ -1,6 +1,7 @@ <script> import { GlBadge, GlButton } from '@gitlab/ui'; import { s__, sprintf, n__ } from '~/locale'; +import { getAccessLevels } from '../../../utils'; export const i18n = { defaultLabel: s__('BranchRules|default'), @@ -9,6 +10,9 @@ export const i18n = { codeOwnerApprovalRequired: s__('BranchRules|Requires CODEOWNERS approval'), statusChecks: s__('BranchRules|%{total} status %{subject}'), approvalRules: s__('BranchRules|%{total} approval %{subject}'), + matchingBranches: s__('BranchRules|%{total} matching %{subject}'), + pushAccessLevels: s__('BranchRules|Allowed to merge'), + mergeAccessLevels: s__('BranchRules|Allowed to push'), }; export default { @@ -48,8 +52,16 @@ export default { required: false, default: 0, }, + matchingBranchesCount: { + type: Number, + required: false, + default: 0, + }, }, computed: { + isWildcard() { + return this.name.includes('*'); + }, hasApprovalDetails() { return this.approvalDetails.length; }, @@ -68,8 +80,31 @@ export default { subject: n__('rule', 'rules', this.approvalRulesTotal), }); }, + matchingBranchesText() { + return sprintf(this.$options.i18n.matchingBranches, { + total: this.matchingBranchesCount, + subject: n__('branch', 'branches', this.matchingBranchesCount), + }); + }, + mergeAccessLevels() { + const { mergeAccessLevels } = this.branchProtection || {}; + return this.getAccessLevels(mergeAccessLevels); + }, + pushAccessLevels() { + const { pushAccessLevels } = this.branchProtection || {}; + return this.getAccessLevels(pushAccessLevels); + }, + pushAccessLevelsText() { + return this.getAccessLevelsText(this.$options.i18n.pushAccessLevels, this.pushAccessLevels); + }, + mergeAccessLevelsText() { + return this.getAccessLevelsText(this.$options.i18n.mergeAccessLevels, this.mergeAccessLevels); + }, approvalDetails() { const approvalDetails = []; + if (this.isWildcard) { + approvalDetails.push(this.matchingBranchesText); + } if (this.branchProtection.allowForcePush) { approvalDetails.push(this.$options.i18n.allowForcePush); } @@ -82,9 +117,31 @@ export default { if (this.approvalRulesTotal) { approvalDetails.push(this.approvalRulesText); } + if (this.mergeAccessLevels.total > 0) { + approvalDetails.push(this.mergeAccessLevelsText); + } + if (this.pushAccessLevels.total > 0) { + approvalDetails.push(this.pushAccessLevelsText); + } return approvalDetails; }, }, + methods: { + getAccessLevels, + getAccessLevelsText(beginString = '', accessLevels) { + const textParts = []; + if (accessLevels.roles.length) { + textParts.push(n__('1 role', '%d roles', accessLevels.roles.length)); + } + if (accessLevels.groups.length) { + textParts.push(n__('1 group', '%d groups', accessLevels.groups.length)); + } + if (accessLevels.users.length) { + textParts.push(n__('1 user', '%d users', accessLevels.users.length)); + } + return `${beginString}: ${textParts.join(', ')}`; + }, + }, }; </script> diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql b/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql index 49e089e7805..a8cdda5505f 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql @@ -5,18 +5,24 @@ query getBranchRules($projectPath: ID!) { nodes { name isDefault + matchingBranchesCount branchProtection { allowForcePush - codeOwnerApprovalRequired - } - externalStatusChecks { - nodes { - id + mergeAccessLevels { + edges { + node { + accessLevel + accessLevelDescription + } + } } - } - approvalRules { - nodes { - id + pushAccessLevels { + edges { + node { + accessLevel + accessLevelDescription + } + } } } } diff --git a/app/assets/javascripts/projects/settings/utils.js b/app/assets/javascripts/projects/settings/utils.js new file mode 100644 index 00000000000..7bcfde39178 --- /dev/null +++ b/app/assets/javascripts/projects/settings/utils.js @@ -0,0 +1,17 @@ +export const getAccessLevels = (accessLevels = {}) => { + const total = accessLevels.edges?.length; + const accessLevelTypes = { total, users: [], groups: [], roles: [] }; + + accessLevels.edges?.forEach(({ node }) => { + if (node.user) { + const src = node.user.avatarUrl; + accessLevelTypes.users.push({ src, ...node.user }); + } else if (node.group) { + accessLevelTypes.groups.push(node); + } else { + accessLevelTypes.roles.push(node); + } + }); + + return accessLevelTypes; +}; 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 71ff3e892b1..b79b3fa4573 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue @@ -1,5 +1,6 @@ <script> -import { GlAlert, GlSprintf, GlLink, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import axios from '~/lib/utils/axios_utils'; import { helpPagePath } from '~/helpers/help_page_helper'; import { __, sprintf } from '~/locale'; @@ -16,7 +17,7 @@ export default { ServiceDeskSetting, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, inject: { initialIsEnabled: { diff --git a/app/assets/javascripts/protected_tags/protected_tag_create.js b/app/assets/javascripts/protected_tags/protected_tag_create.js index e3f427b8408..75fd11cd074 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_create.js +++ b/app/assets/javascripts/protected_tags/protected_tag_create.js @@ -42,7 +42,7 @@ export default class ProtectedTagCreate { const $allowedToCreateInput = this.$form.find('#create_access_levels_attributes'); this.$form - .find('input[type="submit"]') + .find('button[type="submit"]') .prop('disabled', !($tagInput.val() && $allowedToCreateInput.length)); } diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue index 6dc8240e680..1b360b79b0c 100644 --- a/app/assets/javascripts/releases/components/app_index.vue +++ b/app/assets/javascripts/releases/components/app_index.vue @@ -263,6 +263,7 @@ export default { v-for="(release, index) in releases" :key="getReleaseKey(release, index)" :release="release" + :sort="sort" :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }" /> diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue index b2bd405574f..49c349e7a7b 100644 --- a/app/assets/javascripts/releases/components/release_block.vue +++ b/app/assets/javascripts/releases/components/release_block.vue @@ -1,12 +1,12 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import $ from 'jquery'; import { isEmpty } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { scrollToElement } from '~/lib/utils/common_utils'; import { slugify } from '~/lib/utils/text_utility'; import { getLocationHash } from '~/lib/utils/url_utility'; +import { CREATED_ASC } from '~/releases/constants'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import '~/behaviors/markdown/render_gfm'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import EvidenceBlock from './evidence_block.vue'; import ReleaseBlockAssets from './release_block_assets.vue'; import ReleaseBlockFooter from './release_block_footer.vue'; @@ -32,6 +32,11 @@ export default { required: true, default: () => ({}), }, + sort: { + type: String, + required: false, + default: CREATED_ASC, + }, }, data() { return { @@ -80,7 +85,7 @@ export default { }, methods: { renderGFM() { - $(this.$refs['gfm-content']).renderGFM(); + renderGFM(this.$refs['gfm-content']); }, }, safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, @@ -119,6 +124,8 @@ export default { :tag-path="release.tagPath" :author="release.author" :released-at="release.releasedAt" + :created-at="release.createdAt" + :sort="sort" /> </div> </template> diff --git a/app/assets/javascripts/releases/components/release_block_footer.vue b/app/assets/javascripts/releases/components/release_block_footer.vue index 3881c83b5c2..85fb7d02a37 100644 --- a/app/assets/javascripts/releases/components/release_block_footer.vue +++ b/app/assets/javascripts/releases/components/release_block_footer.vue @@ -3,6 +3,7 @@ import { GlTooltipDirective, GlLink, GlIcon } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { RELEASED_AT_ASC, RELEASED_AT_DESC } from '~/releases/constants'; export default { name: 'ReleaseBlockFooter', @@ -46,10 +47,26 @@ export default { required: false, default: null, }, + createdAt: { + type: Date, + required: false, + default: null, + }, + sort: { + type: String, + required: false, + default: '', + }, }, computed: { - releasedAtTimeAgo() { - return this.timeFormatted(this.releasedAt); + isSortedByReleaseDate() { + return this.sort === RELEASED_AT_ASC || this.sort === RELEASED_AT_DESC; + }, + timeAt() { + return this.isSortedByReleaseDate ? this.releasedAt : this.createdAt; + }, + atTimeAgo() { + return this.timeFormatted(this.timeAt); }, userImageAltDescription() { return this.author && this.author.username @@ -58,7 +75,10 @@ export default { }, createdTime() { const now = new Date(); - const isFuture = now < new Date(this.releasedAt); + const isFuture = now < new Date(this.timeAt); + if (this.isSortedByReleaseDate) { + return isFuture ? __('Will be released') : __('Released'); + } return isFuture ? __('Will be created') : __('Created'); }, }, @@ -93,17 +113,17 @@ export default { </div> <div - v-if="releasedAt || author" + v-if="timeAt || author" class="gl-float-left gl-display-flex gl-align-items-center js-author-date-info" > <span class="gl-text-secondary">{{ createdTime }} </span> - <template v-if="releasedAt"> + <template v-if="timeAt"> <span v-gl-tooltip.bottom - :title="tooltipTitle(releasedAt)" + :title="tooltipTitle(timeAt)" class="gl-text-secondary gl-flex-shrink-0" > - {{ releasedAtTimeAgo }} + {{ atTimeAgo }} </span> </template> diff --git a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql index 3ad66afa259..177dff1823e 100644 --- a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql +++ b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql @@ -4,6 +4,7 @@ fragment ReleaseForEditing on Release { tagName description releasedAt + createdAt tagPath assets { links { diff --git a/app/assets/javascripts/releases/util.js b/app/assets/javascripts/releases/util.js index a1027ef08d7..10d7887c0b1 100644 --- a/app/assets/javascripts/releases/util.js +++ b/app/assets/javascripts/releases/util.js @@ -15,7 +15,8 @@ const convertScalarProperties = (graphQLRelease) => 'historicalRelease', ]); -const convertDateProperties = ({ releasedAt }) => ({ +const convertDateProperties = ({ createdAt, releasedAt }) => ({ + createdAt: new Date(createdAt), releasedAt: new Date(releasedAt), }); diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 05d64077866..4d3c1521559 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -1,12 +1,6 @@ <script> -import { - GlTooltipDirective, - GlLink, - GlButton, - GlButtonGroup, - GlLoadingIcon, - GlSafeHtmlDirective, -} from '@gitlab/ui'; +import { GlTooltipDirective, GlLink, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import defaultAvatarUrl from 'images/no_avatar.png'; import pathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql'; import { sprintf, s__ } from '~/locale'; @@ -32,7 +26,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [getRefMixin], apollo: { diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue index 4935b8029f9..8feac6b8e35 100644 --- a/app/assets/javascripts/repository/components/preview/index.vue +++ b/app/assets/javascripts/repository/components/preview/index.vue @@ -1,8 +1,8 @@ <script> -import { GlIcon, GlLink, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import $ from 'jquery'; -import '~/behaviors/markdown/render_gfm'; +import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { handleLocationHash } from '~/lib/utils/common_utils'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import readmeQuery from '../../queries/readme.query.graphql'; export default { @@ -42,7 +42,7 @@ export default { if (newVal) { this.$nextTick(() => { handleLocationHash(); - $(this.$refs.readme).renderGFM(); + renderGFM(this.$refs.readme); }); } }, diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 99eb167172b..46d546c2ee4 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -1,6 +1,5 @@ <script> import { GlSkeletonLoader, GlButton } from '@gitlab/ui'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { sprintf, __ } from '~/locale'; import { joinPaths } from '~/lib/utils/url_utility'; import getRefMixin from '../../mixins/get_ref'; @@ -17,7 +16,7 @@ export default { ParentRow, GlButton, }, - mixins: [getRefMixin, glFeatureFlagMixin()], + mixins: [getRefMixin], apollo: { projectPath: { query: projectPathQuery, @@ -93,9 +92,6 @@ export default { }, generateRowNumber(path, id, index) { const key = `${path}-${id}-${index}`; - if (!this.glFeatures.lazyLoadCommits) { - return 0; - } if (!this.rowNumbers[key] && this.rowNumbers[key] !== 0) { this.$options.totalRowsLoaded += 1; @@ -105,10 +101,6 @@ export default { return this.rowNumbers[key]; }, getCommit(fileName) { - if (!this.glFeatures.lazyLoadCommits) { - return {}; - } - return this.commits.find( (commitEntry) => commitEntry.filePath === joinPaths(this.path, fileName), ); diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index f3c5ace75fc..27ac11f3c58 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -7,10 +7,10 @@ import { GlLoadingIcon, GlIcon, GlHoverLoadDirective, - GlSafeHtmlDirective, GlIntersectionObserver, } from '@gitlab/ui'; import { escapeRegExp } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql'; import { escapeFileUrl } from '~/lib/utils/url_utility'; import { TREE_PAGE_SIZE, ROW_APPEAR_DELAY } from '~/repository/constants'; @@ -19,7 +19,6 @@ import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import blobInfoQuery from 'shared_queries/repository/blob_info.query.graphql'; import getRefMixin from '../../mixins/get_ref'; -import commitQuery from '../../queries/commit.query.graphql'; export default { components: { @@ -35,23 +34,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, GlHoverLoad: GlHoverLoadDirective, - SafeHtml: GlSafeHtmlDirective, - }, - apollo: { - commit: { - query: commitQuery, - variables() { - return { - fileName: this.name, - path: this.currentPath, - projectPath: this.projectPath, - maxOffset: this.totalEntries, - }; - }, - skip() { - return this.glFeatures.lazyLoadCommits; - }, - }, + SafeHtml, }, mixins: [getRefMixin, glFeatureFlagMixin()], props: { @@ -125,14 +108,13 @@ export default { }, data() { return { - commit: null, hasRowAppeared: false, delayedRowAppear: null, }; }, computed: { commitData() { - return this.glFeatures.lazyLoadCommits ? this.commitInfo : this.commit; + return this.commitInfo; }, routerLinkTo() { const blobRouteConfig = { path: `/-/blob/${this.escapedRef}/${escapeFileUrl(this.path)}` }; @@ -200,12 +182,10 @@ export default { return; } - if (this.glFeatures.lazyLoadCommits) { - this.delayedRowAppear = setTimeout( - () => this.$emit('row-appear', this.rowNumber), - ROW_APPEAR_DELAY, - ); - } + this.delayedRowAppear = setTimeout( + () => this.$emit('row-appear', this.rowNumber), + ROW_APPEAR_DELAY, + ); }, rowDisappeared() { clearTimeout(this.delayedRowAppear); diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index 8a45a351c35..4a8f83458f4 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -157,7 +157,7 @@ export default { .find(({ hasNextPage }) => hasNextPage); }, handleRowAppear(rowNumber) { - if (!this.glFeatures.lazyLoadCommits || isRequested(rowNumber)) { + if (isRequested(rowNumber)) { return; } diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index 3a6d7d2f779..e194bddcc56 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -99,5 +99,4 @@ export const LEGACY_FILE_TYPES = [ 'requirements_txt', 'cargo_toml', 'go_mod', - 'go_sum', ]; diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 1d295e18332..e9214e3acff 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -2,11 +2,12 @@ import { GlButton } from '@gitlab/ui'; import Vue from 'vue'; import Vuex from 'vuex'; import { parseBoolean } from '~/lib/utils/common_utils'; -import { escapeFileUrl } from '~/lib/utils/url_utility'; +import { escapeFileUrl, visitUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import PerformancePlugin from '~/performance/vue_performance_plugin'; import createStore from '~/code_navigation/store'; +import RefSelector from '~/ref/components/ref_selector.vue'; import App from './components/app.vue'; import Breadcrumbs from './components/breadcrumbs.vue'; import DirectoryDownloadLinks from './components/directory_download_links.vue'; @@ -20,6 +21,7 @@ import refsQuery from './queries/ref.query.graphql'; import createRouter from './router'; import { updateFormAction } from './utils/dom'; import { setTitle } from './utils/title'; +import { generateRefDestinationPath } from './utils/ref_switcher_utils'; Vue.use(Vuex); Vue.use(PerformancePlugin, { @@ -89,9 +91,34 @@ export default function setupVueRepositoryList() { }, }); - initLastCommitApp(); + const initRefSwitcher = () => { + const refSwitcherEl = document.getElementById('js-tree-ref-switcher'); + + if (!refSwitcherEl) return false; + + const { projectId, projectRootPath } = refSwitcherEl.dataset; + + return new Vue({ + el: refSwitcherEl, + render(createElement) { + return createElement(RefSelector, { + props: { + projectId, + value: ref, + }, + on: { + input(selectedRef) { + visitUrl(generateRefDestinationPath(projectRootPath, selectedRef)); + }, + }, + }); + }, + }); + }; + initLastCommitApp(); initBlobControlsApp(); + initRefSwitcher(); router.afterEach(({ params: { path } }) => { setTitle(path, ref, fullName); diff --git a/app/assets/javascripts/repository/queries/commit.query.graphql b/app/assets/javascripts/repository/queries/commit.query.graphql deleted file mode 100644 index 1a01462bd19..00000000000 --- a/app/assets/javascripts/repository/queries/commit.query.graphql +++ /dev/null @@ -1,7 +0,0 @@ -#import "ee_else_ce/repository/queries/commit.fragment.graphql" - -query getCommit($fileName: String!, $path: String!, $maxOffset: Number!) { - commit(path: $path, fileName: $fileName, maxOffset: $maxOffset) @client { - ...TreeEntryCommit - } -} diff --git a/app/assets/javascripts/repository/utils/ref_switcher_utils.js b/app/assets/javascripts/repository/utils/ref_switcher_utils.js new file mode 100644 index 00000000000..8ff52104c93 --- /dev/null +++ b/app/assets/javascripts/repository/utils/ref_switcher_utils.js @@ -0,0 +1,30 @@ +import { joinPaths } from '~/lib/utils/url_utility'; + +/** + * Matches the namespace and target directory/blob in a path + * Example: /root/Flight/-/blob/fix/main/test/spec/utils_spec.js + * Group 1: /-/blob + * Group 2: blob + * Group 3: main/test/spec/utils_spec.js + */ +const NAMESPACE_TARGET_REGEX = /(\/-\/(blob|tree))\/.*?\/(.*)/; + +/** + * Generates a ref destination path based on the selected ref and current path. + * A user could either be in the project root, a directory on the blob view. + * @param {string} projectRootPath - The root path for a project. + * @param {string} selectedRef - The selected ref from the ref dropdown. + */ +export function generateRefDestinationPath(projectRootPath, selectedRef) { + const currentPath = window.location.pathname; + let namespace = '/-/tree'; + let target; + const match = NAMESPACE_TARGET_REGEX.exec(currentPath); + if (match) { + [, namespace, , target] = match; + } + + const destinationPath = joinPaths(projectRootPath, namespace, selectedRef, target); + + return `${destinationPath}${window.location.hash}`; +} diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue index 38dccb9675d..4ddf695f61a 100644 --- a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue @@ -1,5 +1,5 @@ <script> -import { mapState } from 'vuex'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { confidentialFilterData } from '../constants/confidential_filter_data'; import RadioFilter from './radio_filter.vue'; @@ -8,10 +8,10 @@ export default { components: { RadioFilter, }, + mixins: [glFeatureFlagsMixin()], computed: { - ...mapState(['query']), - showDropdown() { - return Object.values(confidentialFilterData.scopes).includes(this.query.scope); + ffBasedXPadding() { + return this.glFeatures.searchPageVerticalNav ? 'gl-px-5' : 'gl-px-0'; }, }, confidentialFilterData, @@ -19,8 +19,8 @@ export default { </script> <template> - <div v-if="showDropdown"> - <radio-filter :filter-data="$options.confidentialFilterData" /> + <div> + <radio-filter :class="ffBasedXPadding" :filter-data="$options.confidentialFilterData" /> <hr class="gl-my-5 gl-border-gray-100" /> </div> </template> diff --git a/app/assets/javascripts/search/sidebar/components/results_filters.vue b/app/assets/javascripts/search/sidebar/components/results_filters.vue index 5b53f94bb53..9b993ab9a86 100644 --- a/app/assets/javascripts/search/sidebar/components/results_filters.vue +++ b/app/assets/javascripts/search/sidebar/components/results_filters.vue @@ -2,6 +2,8 @@ import { GlButton, GlLink } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { confidentialFilterData } from '../constants/confidential_filter_data'; +import { stateFilterData } from '../constants/state_filter_data'; import ConfidentialityFilter from './confidentiality_filter.vue'; import StatusFilter from './status_filter.vue'; @@ -22,6 +24,15 @@ export default { searchPageVerticalNavFeatureFlag() { return this.glFeatures.searchPageVerticalNav; }, + showConfidentialityFilter() { + return Object.values(confidentialFilterData.scopes).includes(this.urlQuery.scope); + }, + showStatusFilter() { + return Object.values(stateFilterData.scopes).includes(this.urlQuery.scope); + }, + ffBasedXPadding() { + return this.glFeatures.searchPageVerticalNav ? 'gl-px-5' : 'gl-px-0'; + }, }, methods: { ...mapActions(['applyQuery', 'resetQuery']), @@ -30,14 +41,14 @@ export default { </script> <template> - <form - :class="searchPageVerticalNavFeatureFlag ? 'gl-px-5' : 'gl-px-0'" - @submit.prevent="applyQuery" - > - <hr v-if="searchPageVerticalNavFeatureFlag" class="gl-my-5 gl-border-gray-100" /> - <status-filter /> - <confidentiality-filter /> - <div class="gl-display-flex gl-align-items-center gl-mt-4"> + <form class="gl-pt-5 gl-md-pt-0" @submit.prevent="applyQuery"> + <hr + v-if="searchPageVerticalNavFeatureFlag" + class="gl-my-5 gl-border-gray-100 gl-display-none gl-md-display-block" + /> + <status-filter v-if="showStatusFilter" /> + <confidentiality-filter v-if="showConfidentialityFilter" /> + <div class="gl-display-flex gl-align-items-center gl-mt-4" :class="ffBasedXPadding"> <gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty"> {{ __('Apply') }} </gl-button> diff --git a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue index f5e1525090e..7a03306e2f9 100644 --- a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue +++ b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue @@ -1,15 +1,23 @@ <script> -import { GlNav, GlNavItem } from '@gitlab/ui'; +import { GlNav, GlNavItem, GlIcon } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; -import { formatNumber } from '~/locale'; +import { formatNumber, s__ } from '~/locale'; import Tracking from '~/tracking'; -import { NAV_LINK_DEFAULT_CLASSES, NUMBER_FORMATING_OPTIONS } from '../constants'; +import { + NAV_LINK_DEFAULT_CLASSES, + NUMBER_FORMATING_OPTIONS, + NAV_LINK_COUNT_DEFAULT_CLASSES, +} from '../constants'; export default { name: 'ScopeNavigation', + i18n: { + countOverLimitLabel: s__('GlobalSearch|Result count is over limit.'), + }, components: { GlNav, GlNavItem, + GlIcon, }, mixins: [Tracking.mixin()], computed: { @@ -20,9 +28,6 @@ export default { }, methods: { ...mapActions(['fetchSidebarCount']), - activeClasses(currentScope) { - return currentScope === this.urlQuery.scope ? 'gl-font-weight-bold' : ''; - }, showFormatedCount(count) { if (!count) { return '0'; @@ -30,17 +35,27 @@ export default { const countNumber = parseInt(count.replace(/,/g, ''), 10); return formatNumber(countNumber, NUMBER_FORMATING_OPTIONS); }, + isCountOverLimit(count) { + return count.includes('+'); + }, handleClick(scope) { this.track('click_menu_item', { label: `vertical_navigation_${scope}` }); }, - linkClasses(scope) { + linkClasses(isHighlighted) { + return [...this.$options.NAV_LINK_DEFAULT_CLASSES, { 'gl-font-weight-bold': isHighlighted }]; + }, + countClasses(isHighlighted) { return [ - { 'gl-font-weight-bold': scope === this.urlQuery.scope }, - ...this.$options.NAV_LINK_DEFAULT_CLASSES, + ...this.$options.NAV_LINK_COUNT_DEFAULT_CLASSES, + isHighlighted ? 'gl-text-gray-900' : 'gl-text-gray-500', ]; }, + isActive(scope, index) { + return this.urlQuery.scope ? this.urlQuery.scope === scope : index === 0; + }, }, NAV_LINK_DEFAULT_CLASSES, + NAV_LINK_COUNT_DEFAULT_CLASSES, }; </script> @@ -50,14 +65,20 @@ export default { <gl-nav-item v-for="(item, scope, index) in navigation" :key="scope" - :link-classes="linkClasses(scope)" + :link-classes="linkClasses(isActive(scope, index))" class="gl-mb-1" :href="item.link" - :active="urlQuery.scope ? urlQuery.scope === scope : index === 0" + :active="isActive(scope, index)" @click="handleClick(scope)" ><span>{{ item.label }}</span - ><span v-if="item.count" class="gl-font-sm gl-font-weight-normal"> - {{ showFormatedCount(item.count) }} + ><span v-if="item.count" :class="countClasses(isActive(scope, index))"> + {{ showFormatedCount(item.count) + }}<gl-icon + v-if="isCountOverLimit(item.count)" + name="plus" + :aria-label="$options.i18n.countOverLimitLabel" + :size="8" + /> </span> </gl-nav-item> </gl-nav> diff --git a/app/assets/javascripts/search/sidebar/components/status_filter.vue b/app/assets/javascripts/search/sidebar/components/status_filter.vue index 5cec2090906..eaf7d95822a 100644 --- a/app/assets/javascripts/search/sidebar/components/status_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/status_filter.vue @@ -1,5 +1,5 @@ <script> -import { mapState } from 'vuex'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { stateFilterData } from '../constants/state_filter_data'; import RadioFilter from './radio_filter.vue'; @@ -8,10 +8,10 @@ export default { components: { RadioFilter, }, + mixins: [glFeatureFlagsMixin()], computed: { - ...mapState(['query']), - showDropdown() { - return Object.values(stateFilterData.scopes).includes(this.query.scope); + ffBasedXPadding() { + return this.glFeatures.searchPageVerticalNav ? 'gl-px-5' : 'gl-px-0'; }, }, stateFilterData, @@ -19,8 +19,8 @@ export default { </script> <template> - <div v-if="showDropdown"> - <radio-filter :filter-data="$options.stateFilterData" /> + <div> + <radio-filter :class="ffBasedXPadding" :filter-data="$options.stateFilterData" /> <hr class="gl-my-5 gl-border-gray-100" /> </div> </template> diff --git a/app/assets/javascripts/search/sidebar/constants/index.js b/app/assets/javascripts/search/sidebar/constants/index.js index 3621138afe4..a9c031f91a4 100644 --- a/app/assets/javascripts/search/sidebar/constants/index.js +++ b/app/assets/javascripts/search/sidebar/constants/index.js @@ -9,3 +9,5 @@ export const NAV_LINK_DEFAULT_CLASSES = [ 'gl-justify-content-space-between', 'gl-text-gray-900', ]; + +export const NAV_LINK_COUNT_DEFAULT_CLASSES = ['gl-font-sm', 'gl-font-weight-normal']; diff --git a/app/assets/javascripts/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue index d0fcbb0d83b..0629bea3239 100644 --- a/app/assets/javascripts/search/topbar/components/app.vue +++ b/app/assets/javascripts/search/topbar/components/app.vue @@ -1,9 +1,11 @@ <script> -import { GlSearchBoxByClick } from '@gitlab/ui'; +import { GlSearchBoxByClick, GlButton } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { s__ } from '~/locale'; import { parseBoolean } from '~/lib/utils/common_utils'; +import MarkdownDrawer from '~/vue_shared/components/markdown_drawer/markdown_drawer.vue'; +import { SYNTAX_OPTIONS_DOCUMENT } from '../constants'; import GroupFilter from './group_filter.vue'; import ProjectFilter from './project_filter.vue'; @@ -12,24 +14,45 @@ export default { i18n: { searchPlaceholder: s__(`GlobalSearch|Search for projects, issues, etc.`), searchLabel: s__(`GlobalSearch|What are you searching for?`), + documentFetchErrorMessage: s__( + 'GlobalSearch|There was an error fetching the "Syntax Options" document.', + ), + searchFieldLabel: s__('GlobalSearch|What are you searching for?'), + syntaxOptionsLabel: s__('GlobalSearch|Syntax options'), + groupFieldLabel: s__('GlobalSearch|Group'), + projectFieldLabel: s__('GlobalSearch|Project'), + searchButtonLabel: s__('GlobalSearch|Search'), + closeButtonLabel: s__('GlobalSearch|Close'), }, components: { + GlButton, GlSearchBoxByClick, GroupFilter, ProjectFilter, + MarkdownDrawer, }, mixins: [glFeatureFlagsMixin()], props: { - groupInitialData: { + groupInitialJson: { type: Object, required: false, default: () => ({}), }, - projectInitialData: { + projectInitialJson: { type: Object, required: false, default: () => ({}), }, + elasticsearchEnabled: { + type: Boolean, + required: false, + default: false, + }, + defaultBranchName: { + type: String, + required: false, + default: '', + }, }, computed: { ...mapState(['query']), @@ -44,16 +67,26 @@ export default { showFilters() { return !parseBoolean(this.query.snippets); }, + showSyntaxOptions() { + return this.elasticsearchEnabled && this.isDefaultBranch; + }, hasVerticalNav() { return this.glFeatures.searchPageVerticalNav; }, + isDefaultBranch() { + return !this.query.repository_ref || this.query.repository_ref === this.defaultBranchName; + }, }, created() { this.preloadStoredFrequentItems(); }, methods: { ...mapActions(['applyQuery', 'setQuery', 'preloadStoredFrequentItems']), + onToggleDrawer() { + this.$refs.markdownDrawer.toggleDrawer(); + }, }, + SYNTAX_OPTIONS_DOCUMENT, }; </script> @@ -61,7 +94,25 @@ export default { <section class="search-page-form gl-lg-display-flex gl-flex-direction-column"> <div class="gl-lg-display-flex gl-flex-direction-row gl-align-items-flex-end"> <div class="gl-flex-grow-1 gl-mb-4 gl-lg-mb-0 gl-lg-mr-2"> - <label>{{ $options.i18n.searchLabel }}</label> + <div + class="gl-sm-display-flex gl-flex-direction-row gl-justify-content-space-between gl-mb-4 gl-md-mb-0" + > + <label>{{ $options.i18n.searchLabel }}</label> + <template v-if="showSyntaxOptions"> + <gl-button + category="tertiary" + variant="link" + size="small" + button-text-classes="gl-font-sm!" + @click="onToggleDrawer" + >{{ $options.i18n.syntaxOptionsLabel }} + </gl-button> + <markdown-drawer + ref="markdownDrawer" + :document-path="$options.SYNTAX_OPTIONS_DOCUMENT" + /> + </template> + </div> <gl-search-box-by-click id="dashboard_search" v-model="search" @@ -70,13 +121,13 @@ export default { @submit="applyQuery" /> </div> - <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2"> - <label class="gl-display-block">{{ __('Group') }}</label> - <group-filter :initial-data="groupInitialData" /> + <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-3"> + <label class="gl-display-block">{{ $options.i18n.groupFieldLabel }}</label> + <group-filter :initial-data="groupInitialJson" /> </div> - <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2"> - <label class="gl-display-block">{{ __('Project') }}</label> - <project-filter :initial-data="projectInitialData" /> + <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-ml-3"> + <label class="gl-display-block">{{ $options.i18n.projectFieldLabel }}</label> + <project-filter :initial-data="projectInitialJson" /> </div> </div> <hr v-if="hasVerticalNav" class="gl-mt-5 gl-mb-0 gl-border-gray-100" /> diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue index 70156142365..c1e33df3c42 100644 --- a/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue +++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue @@ -1,5 +1,6 @@ <script> -import { GlDropdownItem, GlAvatar, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlDropdownItem, GlAvatar } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import highlight from '~/lib/utils/highlight'; import { truncateNamespace } from '~/lib/utils/text_utility'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; diff --git a/app/assets/javascripts/search/topbar/constants.js b/app/assets/javascripts/search/topbar/constants.js index dc040fdef34..121c15199dd 100644 --- a/app/assets/javascripts/search/topbar/constants.js +++ b/app/assets/javascripts/search/topbar/constants.js @@ -19,3 +19,5 @@ export const PROJECT_DATA = { name: 'name', fullName: 'name_with_namespace', }; + +export const SYNTAX_OPTIONS_DOCUMENT = 'drawers/user/search/advanced_search.md'; diff --git a/app/assets/javascripts/search/topbar/index.js b/app/assets/javascripts/search/topbar/index.js index 87316e10e8d..d6e16085c28 100644 --- a/app/assets/javascripts/search/topbar/index.js +++ b/app/assets/javascripts/search/topbar/index.js @@ -11,10 +11,18 @@ export const initTopbar = (store) => { return false; } - let { groupInitialData, projectInitialData } = el.dataset; + const { + groupInitialJson, + projectInitialJson, + elasticsearchEnabled, + defaultBranchName, + } = el.dataset; - groupInitialData = JSON.parse(groupInitialData); - projectInitialData = JSON.parse(projectInitialData); + const groupInitialJsonParsed = JSON.parse(groupInitialJson); + const projectInitialJsonParsed = JSON.parse(projectInitialJson); + const elasticsearchEnabledParsed = elasticsearchEnabled + ? JSON.parse(elasticsearchEnabled) + : false; return new Vue({ el, @@ -22,8 +30,10 @@ export const initTopbar = (store) => { render(createElement) { return createElement(GlobalSearchTopbar, { props: { - groupInitialData, - projectInitialData, + groupInitialJson: groupInitialJsonParsed, + projectInitialJson: projectInitialJsonParsed, + elasticsearchEnabled: elasticsearchEnabledParsed, + defaultBranchName, }, }); }, diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue index 0bcb2bb6720..6dae8e50908 100644 --- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue +++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue @@ -8,9 +8,9 @@ import { GlLink, GlSkeletonLoader, GlIcon, - GlSafeHtmlDirective, } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import Tracking from '~/tracking'; import { __, s__ } from '~/locale'; import { @@ -54,7 +54,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [Tracking.mixin()], inject: ['projectFullPath'], diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue index ffba3aac681..d9e969e2278 100644 --- a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue +++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue @@ -1,15 +1,8 @@ <script> -import { - GlFormGroup, - GlButton, - GlModal, - GlToast, - GlToggle, - GlLink, - GlSafeHtmlDirective, -} from '@gitlab/ui'; +import { GlFormGroup, GlButton, GlModal, GlToast, GlToggle, GlLink } from '@gitlab/ui'; import Vue from 'vue'; import { mapState, mapActions } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { helpPagePath } from '~/helpers/help_page_helper'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { visitUrl, getBaseURL } from '~/lib/utils/url_utility'; @@ -26,7 +19,7 @@ export default { GlLink, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, formLabels: { createProject: __('Self-monitoring'), diff --git a/app/assets/javascripts/self_monitor/store/actions.js b/app/assets/javascripts/self_monitor/store/actions.js index 5b9e994290c..7198dbe8b04 100644 --- a/app/assets/javascripts/self_monitor/store/actions.js +++ b/app/assets/javascripts/self_monitor/store/actions.js @@ -1,6 +1,6 @@ import axios from '~/lib/utils/axios_utils'; import { backOff } from '~/lib/utils/common_utils'; -import statusCodes from '~/lib/utils/http_status'; +import statusCodes, { HTTP_STATUS_ACCEPTED } from '~/lib/utils/http_status'; import { __, s__ } from '~/locale'; import * as types from './mutation_types'; @@ -10,7 +10,7 @@ function backOffRequest(makeRequestCallback) { return backOff((next, stop) => { makeRequestCallback() .then((resp) => { - if (resp.status === statusCodes.ACCEPTED) { + if (resp.status === HTTP_STATUS_ACCEPTED) { next(); } else { stop(resp); @@ -31,7 +31,7 @@ export const requestCreateProject = ({ dispatch, state, commit }) => { axios .post(state.createProjectEndpoint) .then((resp) => { - if (resp.status === statusCodes.ACCEPTED) { + if (resp.status === HTTP_STATUS_ACCEPTED) { dispatch('requestCreateProjectStatus', resp.data.job_id); } }) @@ -83,7 +83,7 @@ export const requestDeleteProject = ({ dispatch, state, commit }) => { axios .delete(state.deleteProjectEndpoint) .then((resp) => { - if (resp.status === statusCodes.ACCEPTED) { + if (resp.status === HTTP_STATUS_ACCEPTED) { dispatch('requestDeleteProjectStatus', resp.data.job_id); } }) diff --git a/app/assets/javascripts/sentry/constants.js b/app/assets/javascripts/sentry/constants.js index fd96da5faf6..5531c4f56db 100644 --- a/app/assets/javascripts/sentry/constants.js +++ b/app/assets/javascripts/sentry/constants.js @@ -1,5 +1,6 @@ import { __ } from '~/locale'; +// TODO: Remove in favor of https://gitlab.com/gitlab-org/gitlab/issues/35144 export const IGNORE_ERRORS = [ // Random plugins/extensions 'top.GLOBALS', diff --git a/app/assets/javascripts/sentry/index.js b/app/assets/javascripts/sentry/index.js index 176745b4177..5539a061726 100644 --- a/app/assets/javascripts/sentry/index.js +++ b/app/assets/javascripts/sentry/index.js @@ -1,26 +1,34 @@ import '../webpack'; +import * as Sentry from 'sentrybrowser7'; import SentryConfig from './sentry_config'; const index = function index() { + // Configuration for newer versions of Sentry SDK (v7) SentryConfig.init({ dsn: gon.sentry_dsn, + environment: gon.sentry_environment, currentUserId: gon.current_user_id, - whitelistUrls: + allowUrls: process.env.NODE_ENV === 'production' ? [gon.gitlab_url] : [gon.gitlab_url, 'webpack-internal://'], - environment: gon.sentry_environment, release: gon.revision, tags: { revision: gon.revision, feature_category: gon.feature_category, }, }); - - return SentryConfig; }; index(); +// The _Sentry object is globally exported so it can be used by +// ./sentry_browser_wrapper.js +// This hack allows us to load a single version of `@sentry/browser` +// in the browser, see app/views/layouts/_head.html.haml to find how it is imported. + +// eslint-disable-next-line no-underscore-dangle +window._Sentry = Sentry; + export default index; diff --git a/app/assets/javascripts/sentry/legacy_index.js b/app/assets/javascripts/sentry/legacy_index.js new file mode 100644 index 00000000000..604b982e128 --- /dev/null +++ b/app/assets/javascripts/sentry/legacy_index.js @@ -0,0 +1,34 @@ +import '../webpack'; + +import * as Sentry5 from 'sentrybrowser5'; +import LegacySentryConfig from './legacy_sentry_config'; + +const index = function index() { + // Configuration for legacy versions of Sentry SDK (v5) + LegacySentryConfig.init({ + dsn: gon.sentry_dsn, + currentUserId: gon.current_user_id, + whitelistUrls: + process.env.NODE_ENV === 'production' + ? [gon.gitlab_url] + : [gon.gitlab_url, 'webpack-internal://'], + environment: gon.sentry_environment, + release: gon.revision, + tags: { + revision: gon.revision, + feature_category: gon.feature_category, + }, + }); +}; + +index(); + +// The _Sentry object is globally exported so it can be used by +// ./sentry_browser_wrapper.js +// This hack allows us to load a single version of `@sentry/browser` +// in the browser, see app/views/layouts/_head.html.haml to find how it is imported. + +// eslint-disable-next-line no-underscore-dangle +window._Sentry = Sentry5; + +export default index; diff --git a/app/assets/javascripts/sentry/legacy_sentry_config.js b/app/assets/javascripts/sentry/legacy_sentry_config.js new file mode 100644 index 00000000000..50a943886db --- /dev/null +++ b/app/assets/javascripts/sentry/legacy_sentry_config.js @@ -0,0 +1,64 @@ +import * as Sentry5 from 'sentrybrowser5'; +import $ from 'jquery'; +import { __ } from '~/locale'; +import { IGNORE_ERRORS, DENY_URLS, SAMPLE_RATE } from './constants'; + +const SentryConfig = { + IGNORE_ERRORS, + BLACKLIST_URLS: DENY_URLS, + SAMPLE_RATE, + init(options = {}) { + this.options = options; + + this.configure(); + this.bindSentryErrors(); + if (this.options.currentUserId) this.setUser(); + }, + + configure() { + const { dsn, release, tags, whitelistUrls, environment } = this.options; + + Sentry5.init({ + dsn, + release, + whitelistUrls, + environment, + ignoreErrors: this.IGNORE_ERRORS, // TODO: Remove in favor of https://gitlab.com/gitlab-org/gitlab/issues/35144 + blacklistUrls: this.BLACKLIST_URLS, + sampleRate: SAMPLE_RATE, + }); + + Sentry5.setTags(tags); + }, + + setUser() { + Sentry5.setUser({ + id: this.options.currentUserId, + }); + }, + + bindSentryErrors() { + $(document).on('ajaxError.sentry', this.handleSentryErrors); + }, + + handleSentryErrors(event, req, config, err) { + const error = err || req.statusText; + const { responseText = __('Unknown response text') } = req; + const { type, url, data } = config; + const { status } = req; + + Sentry5.captureMessage(error, { + extra: { + type, + url, + data, + status, + response: responseText, + error, + event, + }, + }); + }, +}; + +export default SentryConfig; diff --git a/app/assets/javascripts/sentry/sentry_browser_wrapper.js b/app/assets/javascripts/sentry/sentry_browser_wrapper.js new file mode 100644 index 00000000000..0382827f82c --- /dev/null +++ b/app/assets/javascripts/sentry/sentry_browser_wrapper.js @@ -0,0 +1,27 @@ +// The _Sentry object is globally exported so it can be used here +// This hack allows us to load a single version of `@sentry/browser` +// in the browser (or none). See app/views/layouts/_head.html.haml +// to find how it is imported. + +// This module wraps methods used by our production code. +// Each export is names as we cannot export the entire namespace from *. +export const captureException = (...args) => { + // eslint-disable-next-line no-underscore-dangle + const Sentry = window._Sentry; + + Sentry?.captureException(...args); +}; + +export const captureMessage = (...args) => { + // eslint-disable-next-line no-underscore-dangle + const Sentry = window._Sentry; + + Sentry?.captureMessage(...args); +}; + +export const withScope = (...args) => { + // eslint-disable-next-line no-underscore-dangle + const Sentry = window._Sentry; + + Sentry?.withScope(...args); +}; diff --git a/app/assets/javascripts/sentry/sentry_config.js b/app/assets/javascripts/sentry/sentry_config.js index 4c5b8dbad5a..ed8a55b7d44 100644 --- a/app/assets/javascripts/sentry/sentry_config.js +++ b/app/assets/javascripts/sentry/sentry_config.js @@ -1,30 +1,24 @@ -import * as Sentry from '@sentry/browser'; -import $ from 'jquery'; -import { __ } from '~/locale'; +import * as Sentry from 'sentrybrowser7'; import { IGNORE_ERRORS, DENY_URLS, SAMPLE_RATE } from './constants'; const SentryConfig = { - IGNORE_ERRORS, - BLACKLIST_URLS: DENY_URLS, - SAMPLE_RATE, init(options = {}) { this.options = options; this.configure(); - this.bindSentryErrors(); if (this.options.currentUserId) this.setUser(); }, configure() { - const { dsn, release, tags, whitelistUrls, environment } = this.options; + const { dsn, release, tags, allowUrls, environment } = this.options; Sentry.init({ dsn, release, - whitelistUrls, + allowUrls, environment, - ignoreErrors: this.IGNORE_ERRORS, // TODO: Remove in favor of https://gitlab.com/gitlab-org/gitlab/issues/35144 - blacklistUrls: this.BLACKLIST_URLS, + ignoreErrors: IGNORE_ERRORS, + denyUrls: DENY_URLS, sampleRate: SAMPLE_RATE, }); @@ -36,29 +30,6 @@ const SentryConfig = { id: this.options.currentUserId, }); }, - - bindSentryErrors() { - $(document).on('ajaxError.sentry', this.handleSentryErrors); - }, - - handleSentryErrors(event, req, config, err) { - const error = err || req.statusText; - const { responseText = __('Unknown response text') } = req; - const { type, url, data } = config; - const { status } = req; - - Sentry.captureMessage(error, { - extra: { - type, - url, - data, - status, - response: responseText, - error, - event, - }, - }); - }, }; export default SentryConfig; diff --git a/app/assets/javascripts/set_status_modal/set_status_form.vue b/app/assets/javascripts/set_status_modal/set_status_form.vue index 86049a2b781..dd27a12cbee 100644 --- a/app/assets/javascripts/set_status_modal/set_status_form.vue +++ b/app/assets/javascripts/set_status_modal/set_status_form.vue @@ -10,9 +10,9 @@ import { GlDropdownItem, GlSprintf, GlFormGroup, - GlSafeHtmlDirective, } from '@gitlab/ui'; import $ from 'jquery'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; import * as Emoji from '~/emoji'; import { s__ } from '~/locale'; @@ -33,7 +33,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { defaultEmoji: { 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 80158c55dbc..5becc03646e 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,5 +1,5 @@ <script> -import { GlToast, GlTooltipDirective, GlSafeHtmlDirective, GlModal } from '@gitlab/ui'; +import { GlToast, GlTooltipDirective, GlModal } from '@gitlab/ui'; import Vue from 'vue'; import { createAlert } from '~/flash'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; @@ -19,7 +19,6 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, }, mixins: [glFeatureFlagsMixin()], props: { @@ -110,7 +109,6 @@ export default { this.availability = value; }, }, - safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, actionPrimary: { text: s__('SetStatusModal|Set status') }, actionSecondary: { text: s__('SetStatusModal|Remove status') }, }; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue index 78d12ac113b..93fcf2cf1c9 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue @@ -1,7 +1,7 @@ <script> import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { IssuableType } from '~/issues/constants'; -import { assigneesQueries } from '~/sidebar/constants'; +import { assigneesQueries } from '../../constants'; export default { subscription: null, diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue index 4408ebb881b..fd51cd5bb16 100644 --- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue @@ -1,7 +1,7 @@ <script> import { GlButton } from '@gitlab/ui'; import { n__ } from '~/locale'; -import UncollapsedAssigneeList from '~/sidebar/components/assignees/uncollapsed_assignee_list.vue'; +import UncollapsedAssigneeList from './uncollapsed_assignee_list.vue'; export default { components: { diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index 15fd365b4da..7979f450fdd 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -2,9 +2,9 @@ import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; -import eventHub from '~/sidebar/event_hub'; -import Store from '~/sidebar/stores/sidebar_store'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import eventHub from '../../event_hub'; +import Store from '../../stores/sidebar_store'; import AssigneeTitle from './assignee_title.vue'; import Assignees from './assignees.vue'; import AssigneesRealtime from './assignees_realtime.vue'; diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue index 395dcf73693..d6c679f2f07 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -4,12 +4,12 @@ import Vue from 'vue'; import { createAlert } from '~/flash'; import { IssuableType } from '~/issues/constants'; import { __, n__ } from '~/locale'; -import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; -import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; -import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import { assigneesQueries } from '~/sidebar/constants'; import UserSelect from '~/vue_shared/components/user_select/user_select.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { assigneesQueries } from '../../constants'; +import SidebarEditableItem from '../sidebar_editable_item.vue'; +import SidebarAssigneesRealtime from './assignees_realtime.vue'; +import IssuableAssignees from './issuable_assignees.vue'; import SidebarInviteMembers from './sidebar_invite_members.vue'; export const assigneesWidget = Vue.observable({ diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue index 3532b75b6e7..dbedfe57325 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue @@ -3,7 +3,7 @@ import { GlSprintf, GlButton } from '@gitlab/ui'; import { createAlert } from '~/flash'; import { IssuableType } from '~/issues/constants'; import { __, sprintf } from '~/locale'; -import { confidentialityQueries } from '~/sidebar/constants'; +import { confidentialityQueries } from '../../constants'; export default { i18n: { diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue index f3bd58c11d4..c2f239b56c7 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue @@ -3,8 +3,8 @@ import produce from 'immer'; import Vue from 'vue'; import { createAlert } from '~/flash'; import { __, sprintf } from '~/locale'; -import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import { confidentialityQueries, Tracking } from '~/sidebar/constants'; +import { confidentialityQueries, Tracking } from '../../constants'; +import SidebarEditableItem from '../sidebar_editable_item.vue'; import SidebarConfidentialityContent from './sidebar_confidentiality_content.vue'; import SidebarConfidentialityForm from './sidebar_confidentiality_form.vue'; diff --git a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue b/app/assets/javascripts/sidebar/components/copy/copy_email_to_clipboard.vue index fd652583f76..96ecdc84ef5 100644 --- a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue +++ b/app/assets/javascripts/sidebar/components/copy/copy_email_to_clipboard.vue @@ -1,5 +1,5 @@ <script> -import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue'; +import CopyableField from './copyable_field.vue'; export default { components: { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue b/app/assets/javascripts/sidebar/components/copy/copyable_field.vue index 6538de085b0..6538de085b0 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue +++ b/app/assets/javascripts/sidebar/components/copy/copyable_field.vue diff --git a/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue b/app/assets/javascripts/sidebar/components/copy/sidebar_reference_widget.vue index d07c6e0cbd2..3287539e502 100644 --- a/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue +++ b/app/assets/javascripts/sidebar/components/copy/sidebar_reference_widget.vue @@ -1,7 +1,7 @@ <script> import { __ } from '~/locale'; -import { referenceQueries } from '~/sidebar/constants'; -import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue'; +import { referenceQueries } from '../../constants'; +import CopyableField from './copyable_field.vue'; export default { components: { diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue index 81090bfa062..0660e4f58e4 100644 --- a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue +++ b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue @@ -4,8 +4,8 @@ import { __, n__, sprintf } from '~/locale'; import { createAlert } from '~/flash'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { TYPE_ISSUE } from '~/graphql_shared/constants'; -import getIssueCrmContactsQuery from './queries/get_issue_crm_contacts.query.graphql'; -import issueCrmContactsSubscription from './queries/issue_crm_contacts.subscription.graphql'; +import getIssueCrmContactsQuery from '../../queries/get_issue_crm_contacts.query.graphql'; +import issueCrmContactsSubscription from '../../queries/issue_crm_contacts.subscription.graphql'; export default { components: { diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue index c262d65f6ce..eb48732f558 100644 --- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue +++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue @@ -4,14 +4,8 @@ import { createAlert } from '~/flash'; import { IssuableType } from '~/issues/constants'; import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility'; import { __, sprintf } from '~/locale'; -import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import { - dateFields, - dateTypes, - dueDateQueries, - startDateQueries, - Tracking, -} from '~/sidebar/constants'; +import { dateFields, dateTypes, dueDateQueries, startDateQueries, Tracking } from '../../constants'; +import SidebarEditableItem from '../sidebar_editable_item.vue'; import SidebarFormattedDate from './sidebar_formatted_date.vue'; import SidebarInheritDate from './sidebar_inherit_date.vue'; diff --git a/app/assets/javascripts/sidebar/components/incidents/constants.js b/app/assets/javascripts/sidebar/components/incidents/constants.js deleted file mode 100644 index cd05a6099fd..00000000000 --- a/app/assets/javascripts/sidebar/components/incidents/constants.js +++ /dev/null @@ -1,25 +0,0 @@ -import { s__ } from '~/locale'; - -export const STATUS_TRIGGERED = 'TRIGGERED'; -export const STATUS_ACKNOWLEDGED = 'ACKNOWLEDGED'; -export const STATUS_RESOLVED = 'RESOLVED'; - -export const STATUS_TRIGGERED_LABEL = s__('IncidentManagement|Triggered'); -export const STATUS_ACKNOWLEDGED_LABEL = s__('IncidentManagement|Acknowledged'); -export const STATUS_RESOLVED_LABEL = s__('IncidentManagement|Resolved'); - -export const STATUS_LABELS = { - [STATUS_TRIGGERED]: STATUS_TRIGGERED_LABEL, - [STATUS_ACKNOWLEDGED]: STATUS_ACKNOWLEDGED_LABEL, - [STATUS_RESOLVED]: STATUS_RESOLVED_LABEL, -}; - -export const i18n = { - fetchError: s__( - 'IncidentManagement|An error occurred while fetching the incident status. Please reload the page.', - ), - title: s__('IncidentManagement|Status'), - updateError: s__( - 'IncidentManagement|An error occurred while updating the incident status. Please reload the page and try again.', - ), -}; diff --git a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue index 9c41db98c63..72a572087c7 100644 --- a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue +++ b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue @@ -1,7 +1,12 @@ <script> import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { i18n, STATUS_ACKNOWLEDGED, STATUS_TRIGGERED, STATUS_RESOLVED } from './constants'; -import { getStatusLabel } from './utils'; +import { + INCIDENTS_I18N as i18n, + STATUS_ACKNOWLEDGED, + STATUS_TRIGGERED, + STATUS_RESOLVED, +} from '../../constants'; +import { getStatusLabel } from '../../utils'; const STATUS_LIST = [STATUS_TRIGGERED, STATUS_ACKNOWLEDGED, STATUS_RESOLVED]; diff --git a/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue index 67ae1e6fcab..f7daad63f45 100644 --- a/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue +++ b/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue @@ -1,12 +1,15 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { escalationStatusQuery, escalationStatusMutation } from '~/sidebar/constants'; import { createAlert } from '~/flash'; import { logError } from '~/lib/logger'; import EscalationStatus from 'ee_else_ce/sidebar/components/incidents/escalation_status.vue'; +import { + escalationStatusQuery, + escalationStatusMutation, + INCIDENTS_I18N as i18n, +} from '../../constants'; +import { getStatusLabel } from '../../utils'; import SidebarEditableItem from '../sidebar_editable_item.vue'; -import { i18n } from './constants'; -import { getStatusLabel } from './utils'; export default { i18n, diff --git a/app/assets/javascripts/sidebar/components/incidents/utils.js b/app/assets/javascripts/sidebar/components/incidents/utils.js deleted file mode 100644 index 59bf1ea466c..00000000000 --- a/app/assets/javascripts/sidebar/components/incidents/utils.js +++ /dev/null @@ -1,5 +0,0 @@ -import { s__ } from '~/locale'; - -import { STATUS_LABELS } from './constants'; - -export const getStatusLabel = (status) => STATUS_LABELS[status] ?? s__('IncidentManagement|None'); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/constants.js index 00c54313292..00c54313292 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/constants.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_button.vue index 9388ef4ba45..864d9b308e7 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_button.vue @@ -4,7 +4,7 @@ import { mapActions, mapGetters } from 'vuex'; // @deprecated This component should only be used when there is no GraphQL API. // In most cases you should use -// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget` instead. +// `app/assets/javascripts/sidebar/components/labels/labels_select_widget` instead. export default { components: { GlButton, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue index 1064cbc26e3..89a976d45fa 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue @@ -6,7 +6,7 @@ import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue'; // @deprecated This component should only be used when there is no GraphQL API. // In most cases you should use -// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue` instead. +// `app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue` instead. export default { components: { DropdownContentsLabelsView, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue index 3ff3755de46..b8afa67a947 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue @@ -4,7 +4,7 @@ import { mapState, mapActions } from 'vuex'; // @deprecated This component should only be used when there is no GraphQL API. // In most cases you should use -// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue` instead. +// `app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue` instead. export default { components: { GlButton, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue index e235bfde394..ee6b531c1ca 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue @@ -15,7 +15,7 @@ import LabelItem from './label_item.vue'; // @deprecated This component should only be used when there is no GraphQL API. // In most cases you should use -// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue` instead. +// `app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue` instead. export default { components: { GlIntersectionObserver, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_title.vue index e4325492334..1e9edd222c5 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_title.vue @@ -4,7 +4,7 @@ import { mapState, mapActions } from 'vuex'; // @deprecated This component should only be used when there is no GraphQL API. // In most cases you should use -// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue` instead. +// `app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue` instead. export default { components: { GlButton, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value.vue index e59d150dd43..583f060be8a 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value.vue @@ -7,7 +7,7 @@ import { isScopedLabel } from '~/lib/utils/common_utils'; // @deprecated This component should only be used when there is no GraphQL API. // In most cases you should use -// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue` instead. +// `app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue` instead. export default { components: { GlLabel, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed.vue index 5966c78aa51..e84da6ee12b 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed.vue @@ -4,7 +4,7 @@ import { s__, sprintf } from '~/locale'; // @deprecated This component should only be used when there is no GraphQL API. // In most cases you should use -// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget` instead. +// `app/assets/javascripts/sidebar/components/labels/labels_select_widget` instead. export default { directives: { GlTooltip: GlTooltipDirective, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/label_item.vue index 154e3013acd..135fa9f6228 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/label_item.vue @@ -4,7 +4,7 @@ import { __ } from '~/locale'; // @deprecated This component should only be used when there is no GraphQL API. // In most cases you should use -// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue` instead. +// `app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue` instead. export default { functional: true, props: { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue index e6c29e24f0c..2a78db352d7 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue @@ -17,7 +17,7 @@ Vue.use(Vuex); // @deprecated This component should only be used when there is no GraphQL API. // In most cases you should use -// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue` instead. +// `app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue` instead. export default { store: new Vuex.Store(labelsSelectModule()), components: { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/actions.js index 2dab97826b9..2dab97826b9 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/actions.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/getters.js index ef3eedd9bb2..ef3eedd9bb2 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/getters.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/index.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/index.js index 5f61cb732c8..5f61cb732c8 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/index.js +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/index.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutation_types.js index f26e36031f4..f26e36031f4 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutation_types.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutations.js index c85d9befcbb..c85d9befcbb 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutations.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/state.js index 0185d5f88e1..0185d5f88e1 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/state.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js index cd671b4d8f5..cd671b4d8f5 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue index 27186281c42..83df9056af2 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue @@ -110,6 +110,9 @@ export default { isStandalone() { return isDropdownVariantStandalone(this.variant); }, + isSidebar() { + return isDropdownVariantSidebar(this.variant); + }, }, watch: { localSelectedLabels: { @@ -129,7 +132,7 @@ export default { } }, selectedLabels(newVal) { - if (!this.isDirty) { + if (!this.isDirty || !this.isSidebar) { this.localSelectedLabels = newVal; } }, @@ -159,7 +162,7 @@ export default { }, handleDropdownHide() { this.$emit('closeDropdown'); - if (!isDropdownVariantSidebar(this.variant)) { + if (!this.isSidebar) { this.setLabels(); } }, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue index ce93ad216ec..aa1184ed314 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue @@ -10,7 +10,7 @@ import { import produce from 'immer'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; -import { workspaceLabelsQueries } from '~/sidebar/constants'; +import { workspaceLabelsQueries } from '../../../constants'; import createLabelMutation from './graphql/create_label.mutation.graphql'; import { LabelType } from './constants'; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue index 1d854505d11..c1939dc7785 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue @@ -4,7 +4,7 @@ import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; -import { workspaceLabelsQueries } from '~/sidebar/constants'; +import { workspaceLabelsQueries } from '../../../constants'; import LabelItem from './label_item.vue'; export default { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_footer.vue index e67e704ffb8..e67e704ffb8 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_footer.vue diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue index 154a8e866d0..154a8e866d0 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue index 57e3ee4aaa5..57e3ee4aaa5 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue new file mode 100644 index 00000000000..3a93fc7f3b2 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue @@ -0,0 +1,73 @@ +<script> +import { GlLabel } from '@gitlab/ui'; +import { sortBy } from 'lodash'; +import { isScopedLabel } from '~/lib/utils/common_utils'; + +export default { + components: { + GlLabel, + }, + inject: ['allowScopedLabels'], + props: { + disabled: { + type: Boolean, + required: false, + default: false, + }, + selectedLabels: { + type: Array, + required: true, + }, + allowLabelRemove: { + type: Boolean, + required: true, + }, + labelsFilterBasePath: { + type: String, + required: true, + }, + labelsFilterParam: { + type: String, + required: true, + }, + }, + computed: { + sortedSelectedLabels() { + return sortBy(this.selectedLabels, (label) => isScopedLabel(label)); + }, + }, + methods: { + buildFilterUrl({ title }) { + const { labelsFilterBasePath: basePath, labelsFilterParam: filterParam } = this; + + return `${basePath}?${filterParam}[]=${encodeURIComponent(title)}`; + }, + showScopedLabel(label) { + return this.allowScopedLabels && isScopedLabel(label); + }, + removeLabel(labelId) { + this.$emit('onLabelRemove', labelId); + }, + }, +}; +</script> + +<template> + <div> + <gl-label + v-for="label in sortedSelectedLabels" + :key="label.id" + class="gl-mr-2 gl-mb-2" + :data-qa-label-name="label.title" + :title="label.title" + :description="label.description" + :background-color="label.color" + :target="buildFilterUrl(label)" + :scoped="showScopedLabel(label)" + :show-close-button="allowLabelRemove" + :disabled="disabled" + tooltip-placement="top" + @close="removeLabel(label.id)" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/create_label.mutation.graphql index a9c791091fc..a9c791091fc 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/create_label.mutation.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_labels.query.graphql index c442c17eb88..c442c17eb88 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_labels.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql index cb054e2968f..cb054e2968f 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/group_labels.query.graphql index ce1a69f84c0..ce1a69f84c0 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/group_labels.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql index 2904857270e..2904857270e 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql index e0cdfd91658..e0cdfd91658 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql index a7c24620aad..a7c24620aad 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue index 314ffbaf84c..314ffbaf84c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue index 2c27a69d587..b7b4bbac661 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue @@ -7,11 +7,12 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { IssuableType } from '~/issues/constants'; import { __ } from '~/locale'; -import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import { issuableLabelsQueries } from '~/sidebar/constants'; +import { issuableLabelsQueries } from '../../../constants'; +import SidebarEditableItem from '../../sidebar_editable_item.vue'; import { DEBOUNCE_DROPDOWN_DELAY, DropdownVariant } from './constants'; import DropdownContents from './dropdown_contents.vue'; import DropdownValue from './dropdown_value.vue'; +import EmbeddedLabelsList from './embedded_labels_list.vue'; import { isDropdownVariantSidebar, isDropdownVariantStandalone, @@ -22,6 +23,7 @@ export default { components: { DropdownValue, DropdownContents, + EmbeddedLabelsList, SidebarEditableItem, }, mixins: [glFeatureFlagsMixin()], @@ -50,6 +52,11 @@ export default { required: false, default: false, }, + showEmbeddedLabelsList: { + type: Boolean, + required: false, + default: false, + }, variant: { type: String, required: false, @@ -106,6 +113,11 @@ export default { type: String, required: true, }, + selectedLabels: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { @@ -124,11 +136,21 @@ export default { return this.issuableLabels.map((label) => label.id); }, issuableLabels() { - return this.issuable?.labels.nodes || []; + if (this.iid !== '') { + return this.issuable?.labels.nodes || []; + } + + return this.selectedLabels || []; }, issuableId() { return this.issuable?.id; }, + isRealtimeEnabled() { + return this.glFeatures.realtimeLabels; + }, + isLabelListEnabled() { + return this.showEmbeddedLabelsList && isDropdownVariantEmbedded(this.variant); + }, }, apollo: { issuable: { @@ -311,7 +333,10 @@ export default { } }, handleLabelRemove(labelId) { - this.updateSelectedLabels(this.getRemoveVariables(labelId)); + if (this.iid !== '') { + this.updateSelectedLabels(this.getRemoveVariables(labelId)); + } + this.$emit('onLabelRemove', labelId); }, isDropdownVariantSidebar, @@ -385,22 +410,32 @@ export default { </template> </sidebar-editable-item> </template> - <dropdown-contents - v-else - ref="dropdownContents" - :allow-multiselect="allowMultiselect" - :dropdown-button-text="dropdownButtonText" - :labels-list-title="labelsListTitle" - :footer-create-label-title="footerCreateLabelTitle" - :footer-manage-label-title="footerManageLabelTitle" - :labels-create-title="labelsCreateTitle" - :selected-labels="issuableLabels" - :variant="variant" - :full-path="fullPath" - :workspace-type="workspaceType" - :attr-workspace-path="attrWorkspacePath" - :label-create-type="labelCreateType" - @setLabels="handleDropdownClose" - /> + <template v-else> + <dropdown-contents + ref="dropdownContents" + :allow-multiselect="allowMultiselect" + :dropdown-button-text="dropdownButtonText" + :labels-list-title="labelsListTitle" + :footer-create-label-title="footerCreateLabelTitle" + :footer-manage-label-title="footerManageLabelTitle" + :labels-create-title="labelsCreateTitle" + :selected-labels="issuableLabels" + :variant="variant" + :full-path="fullPath" + :workspace-type="workspaceType" + :attr-workspace-path="attrWorkspacePath" + :label-create-type="labelCreateType" + @setLabels="handleDropdownClose" + /> + <embedded-labels-list + v-if="isLabelListEnabled" + :disabled="labelsSelectInProgress" + :selected-labels="issuableLabels" + :allow-label-remove="allowLabelRemove" + :labels-filter-base-path="labelsFilterBasePath" + :labels-filter-param="labelsFilterParam" + @onLabelRemove="handleLabelRemove" + /> + </template> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/utils.js b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/utils.js index b5cd946a189..b5cd946a189 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/utils.js +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/utils.js diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue index d32d8a7b044..cdce6617591 100644 --- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue @@ -4,8 +4,8 @@ import { mapGetters, mapActions } from 'vuex'; import { __, sprintf } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { createAlert } from '~/flash'; -import eventHub from '~/sidebar/event_hub'; import toast from '~/vue_shared/plugins/global_toast'; +import eventHub from '../../event_hub'; import EditForm from './edit_form.vue'; export default { @@ -111,9 +111,9 @@ export default { </script> <template> - <li v-if="isMergeRequest" class="gl-new-dropdown-item"> + <li v-if="isMergeRequest" class="gl-dropdown-item"> <button type="button" class="dropdown-item" @click="toggleLocked"> - <span class="gl-new-dropdown-item-text-wrapper"> + <span class="gl-dropdown-item-text-wrapper"> <template v-if="isLocked"> {{ __('Unlock merge request') }} </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue b/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue index 02323e5a0c6..02323e5a0c6 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue +++ b/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/components/move_issues_button.vue b/app/assets/javascripts/sidebar/components/move/move_issues_button.vue index 6e287ac3bb7..ab4ac9500ad 100644 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/components/move_issues_button.vue +++ b/app/assets/javascripts/sidebar/components/move/move_issues_button.vue @@ -1,7 +1,6 @@ <script> import { GlAlert } from '@gitlab/ui'; -import IssuableMoveDropdown from '~/vue_shared/components/sidebar/issuable_move_dropdown.vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { logError } from '~/lib/logger'; import { s__ } from '~/locale'; import { @@ -13,7 +12,8 @@ import { import issuableEventHub from '~/issues/list/eventhub'; import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; import getIssuesCountQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql'; -import moveIssueMutation from './graphql/mutations/move_issue.mutation.graphql'; +import moveIssueMutation from '../../queries/move_issue.mutation.graphql'; +import IssuableMoveDropdown from './issuable_move_dropdown.vue'; export default { name: 'MoveIssuesButton', @@ -130,7 +130,7 @@ export default { this.moveInProgress = false; issuableEventHub.$emit('issuables:bulkMoveEnded'); - createFlash({ + createAlert({ message: s__(`Issues|There was an error while moving the issues.`), }); }); diff --git a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue index 46a04725a49..b0556e22a8d 100644 --- a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue +++ b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue @@ -1,6 +1,6 @@ <script> import { __ } from '~/locale'; -import { participantsQueries } from '~/sidebar/constants'; +import { participantsQueries } from '../../constants'; import Participants from './participants.vue'; export default { diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue index 5e1172ad835..7af8dcb4e3e 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue @@ -58,11 +58,21 @@ export default { <collapsed-reviewer-list :users="sortedReviewers" :issuable-type="issuableType" /> <div class="value hide-collapsed"> - <template v-if="hasNoUsers"> - <span class="no-value"> - {{ __('None') }} - </span> - </template> + <span v-if="hasNoUsers" class="no-value" data-testid="no-value"> + {{ __('None') }} + <template v-if="editable"> + - + <button + type="button" + class="gl-button btn-link gl-reset-color!" + data-testid="assign-yourself" + data-qa-selector="assign_yourself_button" + @click="assignSelf" + > + {{ __('assign yourself') }} + </button> + </template> + </span> <uncollapsed-reviewer-list v-else diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue index 5f1350690eb..faa36f3d8d2 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue @@ -5,12 +5,12 @@ import Vue from 'vue'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; -import eventHub from '~/sidebar/event_hub'; -import Store from '~/sidebar/stores/sidebar_store'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import getMergeRequestReviewersQuery from '~/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql'; -import mergeRequestReviewersUpdatedSubscription from '~/vue_shared/components/sidebar/queries/merge_request_reviewers.subscription.graphql'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import eventHub from '../../event_hub'; +import getMergeRequestReviewersQuery from '../../queries/get_merge_request_reviewers.query.graphql'; +import mergeRequestReviewersUpdatedSubscription from '../../queries/merge_request_reviewers.subscription.graphql'; +import Store from '../../stores/sidebar_store'; import ReviewerTitle from './reviewer_title.vue'; import Reviewers from './reviewers.vue'; @@ -143,6 +143,13 @@ export default { eventHub.$off('sidebar.saveReviewers', this.saveReviewers); }, methods: { + reviewBySelf() { + // Notify gl dropdown that we are now assigning to current user + this.$el.parentElement.dispatchEvent(new Event('assignYourself')); + + this.mediator.addSelfReview(); + this.saveReviewers(); + }, saveReviewers() { this.loading = true; @@ -181,6 +188,7 @@ export default { :editable="canUpdate" :issuable-type="issuableType" @request-review="requestReview" + @assign-self="reviewBySelf" /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/severity/constants.js b/app/assets/javascripts/sidebar/components/severity/constants.js deleted file mode 100644 index 4f58ff38121..00000000000 --- a/app/assets/javascripts/sidebar/components/severity/constants.js +++ /dev/null @@ -1,41 +0,0 @@ -import { __, s__ } from '~/locale'; - -export const INCIDENT_SEVERITY = { - CRITICAL: { - value: 'CRITICAL', - icon: 'critical', - label: s__('IncidentManagement|Critical - S1'), - }, - HIGH: { - value: 'HIGH', - icon: 'high', - label: s__('IncidentManagement|High - S2'), - }, - MEDIUM: { - value: 'MEDIUM', - icon: 'medium', - label: s__('IncidentManagement|Medium - S3'), - }, - LOW: { - value: 'LOW', - icon: 'low', - label: s__('IncidentManagement|Low - S4'), - }, - UNKNOWN: { - value: 'UNKNOWN', - icon: 'unknown', - label: s__('IncidentManagement|Unknown'), - }, -}; - -export const ISSUABLE_TYPES = { - INCIDENT: 'incident', -}; - -export const I18N = { - UPDATE_SEVERITY_ERROR: s__('SeverityWidget|There was an error while updating severity.'), - TRY_AGAIN: __('Please try again'), - EDIT: __('Edit'), - SEVERITY: s__('SeverityWidget|Severity'), - SEVERITY_VALUE: s__('SeverityWidget|Severity: %{severity}'), -}; diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue index f02e0c783e1..5b624c17b0c 100644 --- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue +++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue @@ -8,8 +8,8 @@ import { GlButton, } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { INCIDENT_SEVERITY, ISSUABLE_TYPES, I18N } from './constants'; -import updateIssuableSeverity from './graphql/mutations/update_issuable_severity.mutation.graphql'; +import updateIssuableSeverity from '../../queries/update_issuable_severity.mutation.graphql'; +import { INCIDENT_SEVERITY, ISSUABLE_TYPES, SEVERITY_I18N as I18N } from '../../constants'; import SeverityToken from './severity.vue'; export default { diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index a685929cdea..35667495ace 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -6,7 +6,6 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { IssuableType } from '~/issues/constants'; import { timeFor } from '~/lib/utils/datetime_utility'; import { __ } from '~/locale'; -import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { dropdowni18nText, @@ -17,6 +16,7 @@ import { Tracking, } from 'ee_else_ce/sidebar/constants'; import SidebarDropdown from './sidebar_dropdown.vue'; +import SidebarEditableItem from './sidebar_editable_item.vue'; export default { i18n: { diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/components/status_dropdown.vue b/app/assets/javascripts/sidebar/components/status/status_dropdown.vue index ba94932289e..7763ec00091 100644 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/components/status_dropdown.vue +++ b/app/assets/javascripts/sidebar/components/status/status_dropdown.vue @@ -1,7 +1,7 @@ <script> import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { __ } from '~/locale'; -import { statusDropdownOptions } from '../constants'; +import { statusDropdownOptions } from '../../constants'; export default { components: { diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue index 99e7c825b72..0fba1cb5e4e 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue @@ -4,10 +4,10 @@ import { createAlert } from '~/flash'; import { IssuableType } from '~/issues/constants'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { __, sprintf } from '~/locale'; -import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import toast from '~/vue_shared/plugins/global_toast'; -import { subscribedQueries, Tracking } from '~/sidebar/constants'; +import { subscribedQueries, Tracking } from '../../constants'; +import SidebarEditableItem from '../sidebar_editable_item.vue'; const ICON_ON = 'notifications'; const ICON_OFF = 'notifications-off'; @@ -182,7 +182,7 @@ export default { </script> <template> - <gl-dropdown-form v-if="isMergeRequest" class="gl-new-dropdown-item"> + <gl-dropdown-form v-if="isMergeRequest" class="gl-dropdown-item"> <div class="gl-px-5 gl-pb-2 gl-pt-1"> <gl-toggle :value="subscribed" diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/components/subscriptions_dropdown.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions_dropdown.vue index 8774b065c22..4c3ba76d12d 100644 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/components/subscriptions_dropdown.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions_dropdown.vue @@ -1,7 +1,7 @@ <script> import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { __ } from '~/locale'; -import { subscriptionsDropdownOptions } from '../constants'; +import { subscriptionsDropdownOptions } from '../../constants'; export default { subscriptionsDropdownOptions, diff --git a/app/assets/javascripts/sidebar/components/time_tracking/constants.js b/app/assets/javascripts/sidebar/components/time_tracking/constants.js new file mode 100644 index 00000000000..56e986e3b27 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/constants.js @@ -0,0 +1 @@ +export const CREATE_TIMELOG_MODAL_ID = 'create-timelog-modal'; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue b/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue new file mode 100644 index 00000000000..ec8e1ee9952 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue @@ -0,0 +1,227 @@ +<script> +import { + GlFormGroup, + GlFormInput, + GlDatepicker, + GlFormTextarea, + GlModal, + GlAlert, + GlLink, + GlSprintf, +} from '@gitlab/ui'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { formatDate } from '~/lib/utils/datetime_utility'; +import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; +import { joinPaths } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; +import createTimelogMutation from '../../queries/create_timelog.mutation.graphql'; +import { CREATE_TIMELOG_MODAL_ID } from './constants'; + +export default { + components: { + GlDatepicker, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlModal, + GlAlert, + GlLink, + GlSprintf, + }, + inject: ['issuableType'], + props: { + issuableId: { + type: String, + required: true, + }, + }, + data() { + return { + timeSpent: '', + spentAt: null, + summary: '', + isLoading: false, + saveError: '', + }; + }, + computed: { + submitDisabled() { + return this.isLoading || this.timeSpent.length === 0; + }, + primaryProps() { + return { + text: s__('CreateTimelogForm|Save'), + attributes: [ + { + variant: 'confirm', + disabled: this.submitDisabled, + loading: this.isLoading, + }, + ], + }; + }, + cancelProps() { + return { + text: s__('CreateTimelogForm|Cancel'), + }; + }, + timeTrackingDocsPath() { + return joinPaths(gon.relative_url_root || '', '/help/user/project/time_tracking.md'); + }, + issuableTypeName() { + return this.isIssue() + ? s__('CreateTimelogForm|issue') + : s__('CreateTimelogForm|merge request'); + }, + }, + methods: { + resetModal() { + this.isLoading = false; + this.timeSpent = ''; + this.spentAt = null; + this.summary = ''; + this.saveError = ''; + }, + close() { + this.resetModal(); + this.$refs.modal.close(); + }, + registerTimeSpent(event) { + event.preventDefault(); + + if (this.timeSpent.length === 0) { + return; + } + + this.isLoading = true; + this.saveError = ''; + + this.$apollo + .mutate({ + mutation: createTimelogMutation, + variables: { + input: { + timeSpent: this.timeSpent, + spentAt: this.spentAt + ? formatDate(this.spentAt, 'isoDateTime') + : formatDate(Date.now(), 'isoDateTime'), + summary: this.summary, + issuableId: this.getIssuableId(), + }, + }, + }) + .then(({ data }) => { + if (data.timelogCreate?.errors.length) { + this.saveError = data.timelogCreate.errors[0].message || data.timelogCreate.errors[0]; + } else { + this.close(); + } + }) + .catch((error) => { + this.saveError = + error?.message || + s__('CreateTimelogForm|An error occurred while saving the time entry.'); + }) + .finally(() => { + this.isLoading = false; + }); + }, + isIssue() { + return this.issuableType === 'issue'; + }, + getGraphQLEntityType() { + return this.isIssue() ? TYPE_ISSUE : TYPE_MERGE_REQUEST; + }, + updateSpentAtDate(val) { + this.spentAt = val; + }, + getIssuableId() { + return convertToGraphQLId(this.getGraphQLEntityType(), this.issuableId); + }, + }, + CREATE_TIMELOG_MODAL_ID, +}; +</script> + +<template> + <gl-modal + ref="modal" + :title="s__('CreateTimelogForm|Add time entry')" + :modal-id="$options.CREATE_TIMELOG_MODAL_ID" + size="sm" + data-testid="create-timelog-modal" + :action-primary="primaryProps" + :action-cancel="cancelProps" + @primary="registerTimeSpent" + @cancel="close" + @close="close" + @hide="close" + > + <p data-testid="timetracking-docs-link"> + <gl-sprintf + :message=" + s__( + 'CreateTimelogForm|Track time spent on this %{issuableTypeNameStart}%{issuableTypeNameEnd}. %{timeTrackingDocsLinkStart}%{timeTrackingDocsLinkEnd}', + ) + " + > + <template #issuableTypeName>{{ issuableTypeName }}</template> + <template #timeTrackingDocsLink> + <gl-link :href="timeTrackingDocsPath" target="_blank">{{ + s__('CreateTimelogForm|How do I track and estimate time?') + }}</gl-link> + </template> + </gl-sprintf> + </p> + <form + class="gl-display-flex gl-flex-direction-column js-quick-submit" + @submit.prevent="registerTimeSpent" + > + <div class="gl-display-flex gl-gap-3"> + <gl-form-group + key="time-spent" + label-for="time-spent" + :label="s__(`CreateTimelogForm|Time spent`)" + :description="s__(`CreateTimelogForm|Example: 1h 30m`)" + > + <gl-form-input + id="time-spent" + ref="timeSpent" + v-model="timeSpent" + class="gl-form-input-sm" + autocomplete="off" + /> + </gl-form-group> + <gl-form-group + key="spent-at" + optional + label-for="spent-at" + :label="s__(`CreateTimelogForm|Spent at`)" + > + <gl-datepicker + :target="null" + :value="spentAt" + show-clear-button + autocomplete="off" + size="small" + @input="updateSpentAtDate" + @clear="updateSpentAtDate(null)" + /> + </gl-form-group> + </div> + <gl-form-group + :label="s__('CreateTimelogForm|Summary')" + optional + label-for="summary" + class="gl-mb-0" + > + <gl-form-textarea id="summary" v-model="summary" rows="3" :no-resize="true" /> + </gl-form-group> + <gl-alert v-if="saveError" variant="danger" class="gl-mt-5" :dismissible="false"> + {{ saveError }} + </gl-alert> + <!-- This is needed to have the quick-submit behaviour (with Ctrl + Enter or Cmd + Enter) --> + <input type="submit" hidden /> + </form> + </gl-modal> +</template> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue index 91c15061fb9..6cd9596e43f 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue @@ -1,5 +1,6 @@ <script> -import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { joinPaths } from '~/lib/utils/url_utility'; import { sprintf, s__ } from '~/locale'; @@ -9,7 +10,7 @@ export default { GlButton, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, computed: { href() { diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue index 124464088cf..6f4ced06ddf 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue @@ -5,8 +5,8 @@ import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { formatDate, parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility'; import { __, s__ } from '~/locale'; -import { timelogQueries } from '~/sidebar/constants'; -import deleteTimelogMutation from './graphql/mutations/delete_timelog.mutation.graphql'; +import { timelogQueries } from '../../constants'; +import deleteTimelogMutation from '../../queries/delete_timelog.mutation.graphql'; const TIME_DATE_FORMAT = 'mmmm d, yyyy, HH:MM ("UTC:" o)'; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue index 62b05421884..06adc048942 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue @@ -30,6 +30,11 @@ export default { required: false, default: false, }, + canAddTimeEntries: { + type: Boolean, + required: false, + default: true, + }, }, mounted() { this.listenForQuickActions(); @@ -67,6 +72,7 @@ export default { :issuable-id="issuableId" :issuable-iid="issuableIid" :limit-to-hours="limitToHours" + :can-add-time-entries="canAddTimeEntries" /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index 13981c477c6..b32836dc87d 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -9,15 +9,17 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import { IssuableType } from '~/issues/constants'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { s__, __ } from '~/locale'; -import { HOW_TO_TRACK_TIME, timeTrackingQueries } from '~/sidebar/constants'; +import { HOW_TO_TRACK_TIME, timeTrackingQueries } from '../../constants'; import eventHub from '../../event_hub'; import TimeTrackingCollapsedState from './collapsed_state.vue'; import TimeTrackingComparisonPane from './comparison_pane.vue'; -import TimeTrackingHelpState from './help_state.vue'; import TimeTrackingReport from './report.vue'; import TimeTrackingSpentOnlyPane from './spent_only_pane.vue'; +import { CREATE_TIMELOG_MODAL_ID } from './constants'; +import CreateTimelogForm from './create_timelog_form.vue'; export default { name: 'IssuableTimeTracker', @@ -34,8 +36,8 @@ export default { TimeTrackingCollapsedState, TimeTrackingSpentOnlyPane, TimeTrackingComparisonPane, - TimeTrackingHelpState, TimeTrackingReport, + CreateTimelogForm, }, directives: { GlModal: GlModalDirective, @@ -87,6 +89,11 @@ export default { default: true, required: false, }, + canAddTimeEntries: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -192,12 +199,12 @@ export default { eventHub.$on('timeTracker:refresh', this.refresh); }, methods: { - toggleHelpState(show) { - this.showHelp = show; - }, refresh() { this.$apollo.queries.issuableTimeTracking.refetch(); }, + openRegisterTimeSpentModal() { + this.$root.$emit(BV_SHOW_MODAL, CREATE_TIMELOG_MODAL_ID); + }, }, }; </script> @@ -215,24 +222,21 @@ export default { :time-estimate-human-readable="humanTimeEstimate" /> <div - class="hide-collapsed gl-line-height-20 gl-text-gray-900 gl-display-flex gl-align-items-center gl-font-weight-bold gl-mr-3" + class="hide-collapsed gl-line-height-20 gl-text-gray-900 gl-display-flex gl-align-items-center gl-font-weight-bold" > {{ __('Time tracking') }} <gl-loading-icon v-if="isTimeTrackingInfoLoading" size="sm" class="gl-ml-2" inline /> <gl-button - :data-testid="showHelpState ? 'closeHelpButton' : 'helpButton'" + v-if="canAddTimeEntries" + v-gl-tooltip.left category="tertiary" size="small" - variant="link" class="gl-ml-auto" - @click="toggleHelpState(!showHelpState)" + data-testid="add-time-entry-button" + :title="__('Add time entry')" + @click="openRegisterTimeSpentModal()" > - <gl-icon - v-gl-tooltip.left - :title="timeTrackingIconTitle" - :name="timeTrackingIconName" - class="gl-text-gray-900!" - /> + <gl-icon name="plus" class="gl-text-gray-900!" /> </gl-button> </div> <div v-if="!isTimeTrackingInfoLoading" class="hide-collapsed"> @@ -272,9 +276,7 @@ export default { <time-tracking-report :limit-to-hours="limitToHours" :issuable-id="issuableId" /> </gl-modal> </template> - <transition name="help-state-toggle"> - <time-tracking-help-state v-if="showHelpState" /> - </transition> + <create-timelog-form :issuable-id="issuableId" /> </div> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue index 5da2d65723a..b86ff279fd8 100644 --- a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue @@ -3,11 +3,11 @@ import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { produce } from 'immer'; import { createAlert } from '~/flash'; import { __, sprintf } from '~/locale'; -import { todoQueries, TodoMutationTypes, todoMutations } from '~/sidebar/constants'; -import { todoLabel } from '~/vue_shared/components/sidebar/todo_toggle//utils'; -import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import Tracking from '~/tracking'; +import { todoQueries, TodoMutationTypes, todoMutations } from '../../constants'; +import { todoLabel } from '../../utils'; +import TodoButton from './todo_button.vue'; const trackingMixin = Tracking.mixin(); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo_button.vue index cdc7422c7df..b49b8fc389b 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo_button.vue @@ -1,6 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; -import { todoLabel, updateGlobalTodoCount } from './utils'; +import { todoLabel, updateGlobalTodoCount } from '../../utils'; export default { components: { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue index 6dacf4e10d3..6dacf4e10d3 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index 67b9b540e91..825a89daf58 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -4,55 +4,55 @@ import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutatio import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; import userSearchWithMRPermissionsQuery from '~/graphql_shared/queries/users_search_with_mr_permissions.graphql'; import { IssuableType, WorkspaceType } from '~/issues/constants'; -import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql'; -import epicDueDateQuery from '~/sidebar/queries/epic_due_date.query.graphql'; -import epicParticipantsQuery from '~/sidebar/queries/epic_participants.query.graphql'; -import epicReferenceQuery from '~/sidebar/queries/epic_reference.query.graphql'; -import epicStartDateQuery from '~/sidebar/queries/epic_start_date.query.graphql'; -import epicSubscribedQuery from '~/sidebar/queries/epic_subscribed.query.graphql'; -import epicTodoQuery from '~/sidebar/queries/epic_todo.query.graphql'; -import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql'; -import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql'; -import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql'; -import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql'; -import issueSubscribedQuery from '~/sidebar/queries/issue_subscribed.query.graphql'; -import issueTimeTrackingQuery from '~/sidebar/queries/issue_time_tracking.query.graphql'; -import issueTodoQuery from '~/sidebar/queries/issue_todo.query.graphql'; -import mergeRequestMilestone from '~/sidebar/queries/merge_request_milestone.query.graphql'; -import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql'; -import mergeRequestSubscribed from '~/sidebar/queries/merge_request_subscribed.query.graphql'; -import mergeRequestTimeTrackingQuery from '~/sidebar/queries/merge_request_time_tracking.query.graphql'; -import mergeRequestTodoQuery from '~/sidebar/queries/merge_request_todo.query.graphql'; -import todoCreateMutation from '~/sidebar/queries/todo_create.mutation.graphql'; -import todoMarkDoneMutation from '~/sidebar/queries/todo_mark_done.mutation.graphql'; -import updateEpicConfidentialMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql'; -import updateEpicDueDateMutation from '~/sidebar/queries/update_epic_due_date.mutation.graphql'; -import updateEpicStartDateMutation from '~/sidebar/queries/update_epic_start_date.mutation.graphql'; -import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql'; -import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_confidential.mutation.graphql'; -import updateIssueDueDateMutation from '~/sidebar/queries/update_issue_due_date.mutation.graphql'; -import updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subscription.mutation.graphql'; -import mergeRequestMilestoneMutation from '~/sidebar/queries/update_merge_request_milestone.mutation.graphql'; -import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql'; -import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql'; import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql'; -import epicLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql'; -import updateEpicLabelsMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql'; -import groupLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql'; -import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql'; -import mergeRequestLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql'; -import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; -import getAlertAssignees from '~/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql'; -import getIssueAssignees from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql'; -import issueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; -import getIssueTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql'; -import getMergeRequestAssignees from '~/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql'; -import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql'; -import getMrTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql'; -import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql'; -import updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql'; -import getEscalationStatusQuery from '~/sidebar/queries/escalation_status.query.graphql'; -import updateEscalationStatusMutation from '~/sidebar/queries/update_escalation_status.mutation.graphql'; +import epicLabelsQuery from './components/labels/labels_select_widget/graphql/epic_labels.query.graphql'; +import updateEpicLabelsMutation from './components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql'; +import groupLabelsQuery from './components/labels/labels_select_widget/graphql/group_labels.query.graphql'; +import issueLabelsQuery from './components/labels/labels_select_widget/graphql/issue_labels.query.graphql'; +import mergeRequestLabelsQuery from './components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql'; +import projectLabelsQuery from './components/labels/labels_select_widget/graphql/project_labels.query.graphql'; +import epicConfidentialQuery from './queries/epic_confidential.query.graphql'; +import epicDueDateQuery from './queries/epic_due_date.query.graphql'; +import epicParticipantsQuery from './queries/epic_participants.query.graphql'; +import epicReferenceQuery from './queries/epic_reference.query.graphql'; +import epicStartDateQuery from './queries/epic_start_date.query.graphql'; +import epicSubscribedQuery from './queries/epic_subscribed.query.graphql'; +import epicTodoQuery from './queries/epic_todo.query.graphql'; +import issuableAssigneesSubscription from './queries/issuable_assignees.subscription.graphql'; +import issueConfidentialQuery from './queries/issue_confidential.query.graphql'; +import issueDueDateQuery from './queries/issue_due_date.query.graphql'; +import issueReferenceQuery from './queries/issue_reference.query.graphql'; +import issueSubscribedQuery from './queries/issue_subscribed.query.graphql'; +import issueTimeTrackingQuery from './queries/issue_time_tracking.query.graphql'; +import issueTodoQuery from './queries/issue_todo.query.graphql'; +import mergeRequestMilestone from './queries/merge_request_milestone.query.graphql'; +import mergeRequestReferenceQuery from './queries/merge_request_reference.query.graphql'; +import mergeRequestSubscribed from './queries/merge_request_subscribed.query.graphql'; +import mergeRequestTimeTrackingQuery from './queries/merge_request_time_tracking.query.graphql'; +import mergeRequestTodoQuery from './queries/merge_request_todo.query.graphql'; +import todoCreateMutation from './queries/todo_create.mutation.graphql'; +import todoMarkDoneMutation from './queries/todo_mark_done.mutation.graphql'; +import updateEpicConfidentialMutation from './queries/update_epic_confidential.mutation.graphql'; +import updateEpicDueDateMutation from './queries/update_epic_due_date.mutation.graphql'; +import updateEpicStartDateMutation from './queries/update_epic_start_date.mutation.graphql'; +import updateEpicSubscriptionMutation from './queries/update_epic_subscription.mutation.graphql'; +import updateIssueConfidentialMutation from './queries/update_issue_confidential.mutation.graphql'; +import updateIssueDueDateMutation from './queries/update_issue_due_date.mutation.graphql'; +import updateIssueSubscriptionMutation from './queries/update_issue_subscription.mutation.graphql'; +import mergeRequestMilestoneMutation from './queries/update_merge_request_milestone.mutation.graphql'; +import updateMergeRequestLabelsMutation from './queries/update_merge_request_labels.mutation.graphql'; +import updateMergeRequestSubscriptionMutation from './queries/update_merge_request_subscription.mutation.graphql'; +import getAlertAssignees from './queries/get_alert_assignees.query.graphql'; +import getIssueAssignees from './queries/get_issue_assignees.query.graphql'; +import issueParticipantsQuery from './queries/get_issue_participants.query.graphql'; +import getIssueTimelogsQuery from './queries/get_issue_timelogs.query.graphql'; +import getMergeRequestAssignees from './queries/get_mr_assignees.query.graphql'; +import getMergeRequestParticipants from './queries/get_mr_participants.query.graphql'; +import getMrTimelogsQuery from './queries/get_mr_timelogs.query.graphql'; +import updateIssueAssigneesMutation from './queries/update_issue_assignees.mutation.graphql'; +import updateMergeRequestAssigneesMutation from './queries/update_mr_assignees.mutation.graphql'; +import getEscalationStatusQuery from './queries/escalation_status.query.graphql'; +import updateEscalationStatusMutation from './queries/update_escalation_status.mutation.graphql'; import groupMilestonesQuery from './queries/group_milestones.query.graphql'; import projectIssueMilestoneMutation from './queries/project_issue_milestone.mutation.graphql'; import projectIssueMilestoneQuery from './queries/project_issue_milestone.query.graphql'; @@ -350,3 +350,94 @@ export const escalationStatusQuery = getEscalationStatusQuery; export const escalationStatusMutation = updateEscalationStatusMutation; export const HOW_TO_TRACK_TIME = __('How to track time'); + +export const statusDropdownOptions = [ + { + text: __('Open'), + value: 'reopen', + }, + { + text: __('Closed'), + value: 'close', + }, +]; + +export const subscriptionsDropdownOptions = [ + { + text: __('Subscribe'), + value: 'subscribe', + }, + { + text: __('Unsubscribe'), + value: 'unsubscribe', + }, +]; + +export const INCIDENT_SEVERITY = { + CRITICAL: { + value: 'CRITICAL', + icon: 'critical', + label: s__('IncidentManagement|Critical - S1'), + }, + HIGH: { + value: 'HIGH', + icon: 'high', + label: s__('IncidentManagement|High - S2'), + }, + MEDIUM: { + value: 'MEDIUM', + icon: 'medium', + label: s__('IncidentManagement|Medium - S3'), + }, + LOW: { + value: 'LOW', + icon: 'low', + label: s__('IncidentManagement|Low - S4'), + }, + UNKNOWN: { + value: 'UNKNOWN', + icon: 'unknown', + label: s__('IncidentManagement|Unknown'), + }, +}; + +export const ISSUABLE_TYPES = { + INCIDENT: 'incident', +}; + +export const MILESTONE_STATE = { + ACTIVE: 'active', + CLOSED: 'closed', +}; + +export const SEVERITY_I18N = { + UPDATE_SEVERITY_ERROR: s__('SeverityWidget|There was an error while updating severity.'), + TRY_AGAIN: __('Please try again'), + EDIT: __('Edit'), + SEVERITY: s__('SeverityWidget|Severity'), + SEVERITY_VALUE: s__('SeverityWidget|Severity: %{severity}'), +}; + +export const STATUS_TRIGGERED = 'TRIGGERED'; +export const STATUS_ACKNOWLEDGED = 'ACKNOWLEDGED'; +export const STATUS_RESOLVED = 'RESOLVED'; + +export const STATUS_TRIGGERED_LABEL = s__('IncidentManagement|Triggered'); +export const STATUS_ACKNOWLEDGED_LABEL = s__('IncidentManagement|Acknowledged'); +export const STATUS_RESOLVED_LABEL = s__('IncidentManagement|Resolved'); + +export const STATUS_LABELS = { + [STATUS_TRIGGERED]: STATUS_TRIGGERED_LABEL, + [STATUS_ACKNOWLEDGED]: STATUS_ACKNOWLEDGED_LABEL, + [STATUS_RESOLVED]: STATUS_RESOLVED_LABEL, +}; + +export const INCIDENTS_I18N = { + fetchError: s__( + 'IncidentManagement|An error occurred while fetching the incident status. Please reload the page.', + ), + title: s__('IncidentManagement|Status'), + updateError: s__( + 'IncidentManagement|An error occurred while updating the incident status. Please reload the page and try again.', + ), +}; diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js index afce59d304f..b908cf0cd9e 100644 --- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js @@ -39,6 +39,7 @@ export default class SidebarMilestone { humanTimeEstimate, humanTotalTimeSpent: humanTimeSpent, }, + canAddTimeEntries: false, }, }), }); diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index b37486283ca..a308dc8d13c 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -6,6 +6,7 @@ import { convertToGraphQLId } from '~/graphql_shared/utils'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { IssuableType } from '~/issues/constants'; +import { gqlClient } from '~/issues/list/graphql'; import { isInIssuePage, isInDesignPage, @@ -14,33 +15,36 @@ import { parseBoolean, } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; -import CollapsedAssigneeList from '~/sidebar/components/assignees/collapsed_assignee_list.vue'; -import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; -import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; -import SidebarDueDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; -import MilestoneDropdown from '~/sidebar/components/milestone/milestone_dropdown.vue'; -import SidebarParticipantsWidget from '~/sidebar/components/participants/sidebar_participants_widget.vue'; -import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue'; -import SidebarDropdownWidget from '~/sidebar/components/sidebar_dropdown_widget.vue'; -import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import { apolloProvider } from '~/graphql_shared/issuable_client'; -import trackShowInviteMemberLink from '~/sidebar/track_invite_members'; -import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; -import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; -import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; -import Translate from '../vue_shared/translate'; +import Translate from '~/vue_shared/translate'; +import CollapsedAssigneeList from './components/assignees/collapsed_assignee_list.vue'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; -import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue'; +import SidebarAssigneesWidget from './components/assignees/sidebar_assignees_widget.vue'; +import SidebarConfidentialityWidget from './components/confidential/sidebar_confidentiality_widget.vue'; +import CopyEmailToClipboard from './components/copy/copy_email_to_clipboard.vue'; +import SidebarDueDateWidget from './components/date/sidebar_date_widget.vue'; import SidebarEscalationStatus from './components/incidents/sidebar_escalation_status.vue'; +import { DropdownVariant } from './components/labels/labels_select_vue/constants'; +import { LabelType } from './components/labels/labels_select_widget/constants'; +import LabelsSelectWidget from './components/labels/labels_select_widget/labels_select_root.vue'; import IssuableLockForm from './components/lock/issuable_lock_form.vue'; +import MilestoneDropdown from './components/milestone/milestone_dropdown.vue'; +import MoveIssuesButton from './components/move/move_issues_button.vue'; +import SidebarParticipantsWidget from './components/participants/sidebar_participants_widget.vue'; +import SidebarReferenceWidget from './components/copy/sidebar_reference_widget.vue'; import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue'; import SidebarReviewersInputs from './components/reviewers/sidebar_reviewers_inputs.vue'; import SidebarSeverity from './components/severity/sidebar_severity.vue'; +import SidebarDropdownWidget from './components/sidebar_dropdown_widget.vue'; +import StatusDropdown from './components/status/status_dropdown.vue'; import SidebarSubscriptionsWidget from './components/subscriptions/sidebar_subscriptions_widget.vue'; +import SubscriptionsDropdown from './components/subscriptions/subscriptions_dropdown.vue'; import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue'; +import SidebarTodoWidget from './components/todo_toggle/sidebar_todo_widget.vue'; import { IssuableAttributeType } from './constants'; -import SidebarMoveIssue from './lib/sidebar_move_issue'; import CrmContacts from './components/crm_contacts/crm_contacts.vue'; +import SidebarMoveIssue from './lib/sidebar_move_issue'; +import trackShowInviteMemberLink from './track_invite_members'; Vue.use(Translate); Vue.use(VueApollo); @@ -540,7 +544,15 @@ function mountSidebarSubscriptionsWidget() { function mountSidebarTimeTracking() { const el = document.querySelector('.js-sidebar-time-tracking-root'); - const { id, iid, fullPath, issuableType, timeTrackingLimitToHours } = getSidebarOptions(); + + const { + id, + iid, + fullPath, + issuableType, + timeTrackingLimitToHours, + canCreateTimelogs, + } = getSidebarOptions(); if (!el) { return null; @@ -558,6 +570,7 @@ function mountSidebarTimeTracking() { issuableId: id.toString(), issuableIid: iid.toString(), limitToHours: timeTrackingLimitToHours, + canAddTimeEntries: canCreateTimelogs, }, }), }); @@ -635,6 +648,59 @@ function mountCopyEmailToClipboard() { }); } +export function mountMoveIssuesButton() { + const el = document.querySelector('.js-move-issues'); + + if (!el) { + return null; + } + + Vue.use(VueApollo); + + return new Vue({ + el, + name: 'MoveIssuesRoot', + apolloProvider: new VueApollo({ + defaultClient: gqlClient, + }), + render: (createElement) => + createElement(MoveIssuesButton, { + props: { + projectFullPath: el.dataset.projectFullPath, + projectsFetchPath: el.dataset.projectsFetchPath, + }, + }), + }); +} + +export function mountStatusDropdown() { + const el = document.querySelector('.js-status-dropdown'); + + if (!el) { + return null; + } + + return new Vue({ + el, + name: 'StatusDropdownRoot', + render: (createElement) => createElement(StatusDropdown), + }); +} + +export function mountSubscriptionsDropdown() { + const el = document.querySelector('.js-subscriptions-dropdown'); + + if (!el) { + return null; + } + + return new Vue({ + el, + name: 'SubscriptionsDropdownRoot', + render: (createElement) => createElement(SubscriptionsDropdown), + }); +} + const isAssigneesWidgetShown = (isInIssuePage() || isInDesignPage() || isInMRPage()) && gon.features.issueAssigneesWidget; diff --git a/app/assets/javascripts/sidebar/queries/create_timelog.mutation.graphql b/app/assets/javascripts/sidebar/queries/create_timelog.mutation.graphql new file mode 100644 index 00000000000..a8692387a46 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/create_timelog.mutation.graphql @@ -0,0 +1,17 @@ +#import "~/graphql_shared/fragments/issue_time_tracking.fragment.graphql" +#import "~/graphql_shared/fragments/merge_request_time_tracking.fragment.graphql" + +mutation createTimelog($input: TimelogCreateInput!) { + timelogCreate(input: $input) { + errors + timelog { + id + issue { + ...IssueTimeTrackingFragment + } + mergeRequest { + ...MergeRequestTimeTrackingFragment + } + } + } +} diff --git a/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql b/app/assets/javascripts/sidebar/queries/delete_timelog.mutation.graphql index 6e916893b5a..6e916893b5a 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/delete_timelog.mutation.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql b/app/assets/javascripts/sidebar/queries/get_alert_assignees.query.graphql index bb6c7181e5c..171eca50eab 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql +++ b/app/assets/javascripts/sidebar/queries/get_alert_assignees.query.graphql @@ -9,6 +9,7 @@ query alertAssignees( workspace: project(fullPath: $fullPath) { id issuable: alertManagementAlert(domain: $domain, iid: $iid) { + id iid assignees { nodes { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql b/app/assets/javascripts/sidebar/queries/get_issue_assignees.query.graphql index 4af07366a6d..4af07366a6d 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql +++ b/app/assets/javascripts/sidebar/queries/get_issue_assignees.query.graphql diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql b/app/assets/javascripts/sidebar/queries/get_issue_crm_contacts.query.graphql index 30a0af10d56..30a0af10d56 100644 --- a/app/assets/javascripts/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql +++ b/app/assets/javascripts/sidebar/queries/get_issue_crm_contacts.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/sidebar/queries/get_issue_participants.query.graphql index eae5e96ac46..eae5e96ac46 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql +++ b/app/assets/javascripts/sidebar/queries/get_issue_participants.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql b/app/assets/javascripts/sidebar/queries/get_issue_timelogs.query.graphql index b127b8ec5a9..b127b8ec5a9 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql +++ b/app/assets/javascripts/sidebar/queries/get_issue_timelogs.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql b/app/assets/javascripts/sidebar/queries/get_merge_request_reviewers.query.graphql index f087ca6c982..f087ca6c982 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql +++ b/app/assets/javascripts/sidebar/queries/get_merge_request_reviewers.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql b/app/assets/javascripts/sidebar/queries/get_mr_assignees.query.graphql index f70cd723f2e..f70cd723f2e 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql +++ b/app/assets/javascripts/sidebar/queries/get_mr_assignees.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql b/app/assets/javascripts/sidebar/queries/get_mr_participants.query.graphql index 2781ac71f31..2781ac71f31 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql +++ b/app/assets/javascripts/sidebar/queries/get_mr_participants.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql b/app/assets/javascripts/sidebar/queries/get_mr_timelogs.query.graphql index 17f548b44b5..17f548b44b5 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql +++ b/app/assets/javascripts/sidebar/queries/get_mr_timelogs.query.graphql diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.fragment.graphql b/app/assets/javascripts/sidebar/queries/issue_crm_contacts.fragment.graphql index 750e1f1d1af..750e1f1d1af 100644 --- a/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.fragment.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_crm_contacts.fragment.graphql diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql b/app/assets/javascripts/sidebar/queries/issue_crm_contacts.subscription.graphql index f3b6e4ec06f..f3b6e4ec06f 100644 --- a/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_crm_contacts.subscription.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/merge_request_reviewers.subscription.graphql b/app/assets/javascripts/sidebar/queries/merge_request_reviewers.subscription.graphql index a1b16b378b3..a1b16b378b3 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/merge_request_reviewers.subscription.graphql +++ b/app/assets/javascripts/sidebar/queries/merge_request_reviewers.subscription.graphql diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/components/graphql/mutations/move_issue.mutation.graphql b/app/assets/javascripts/sidebar/queries/move_issue.mutation.graphql index d350072425b..d350072425b 100644 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/components/graphql/mutations/move_issue.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/move_issue.mutation.graphql diff --git a/app/assets/javascripts/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_issuable_severity.mutation.graphql index c9d36dfdb67..c9d36dfdb67 100644 --- a/app/assets/javascripts/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_issuable_severity.mutation.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_issue_assignees.mutation.graphql index 24de5ea4fe3..24de5ea4fe3 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_issue_assignees.mutation.graphql diff --git a/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_issue_lock.mutation.graphql index cb9ee6abc9b..cb9ee6abc9b 100644 --- a/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_issue_lock.mutation.graphql diff --git a/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_merge_request_lock.mutation.graphql index 11eb3611006..11eb3611006 100644 --- a/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_merge_request_lock.mutation.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_mr_assignees.mutation.graphql index 5fec2ccbdfb..5fec2ccbdfb 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_mr_assignees.mutation.graphql diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index 912f0fdcbef..c6a66ab2275 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -1,9 +1,9 @@ -import Store from '~/sidebar/stores/sidebar_store'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; import toast from '~/vue_shared/plugins/global_toast'; -import { visitUrl } from '../lib/utils/url_utility'; +import { visitUrl } from '~/lib/utils/url_utility'; import Service from './services/sidebar_service'; +import Store from './stores/sidebar_store'; export default class SidebarMediator { constructor(options) { @@ -31,6 +31,9 @@ export default class SidebarMediator { assignYourself() { this.store.addAssignee(this.store.currentUser); } + addSelfReview() { + this.store.addReviewer(this.store.currentUser); + } async saveAssignees(field) { const selected = this.store.assignees.map((u) => u.id); @@ -56,12 +59,14 @@ export default class SidebarMediator { } async saveReviewers(field) { - const selected = this.store.reviewers.map((u) => u.id); + const selectedReviewers = this.store.reviewers; + const selectedIds = selectedReviewers.map((u) => u.id); + const suggestedSelectedIds = selectedReviewers.filter((u) => u.suggested).map((u) => u.id); // If there are no ids, that means we have to unassign (which is id = 0) // And it only accepts an array, hence [0] - const reviewers = selected.length === 0 ? [0] : selected; - const data = { reviewer_ids: reviewers }; + const reviewers = selectedIds.length === 0 ? [0] : selectedIds; + const data = { reviewer_ids: reviewers, suggested_reviewer_ids: suggestedSelectedIds }; try { const res = await this.service.update(field, data); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js b/app/assets/javascripts/sidebar/utils.js index 098ab72dfb5..6b90fb80abf 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js +++ b/app/assets/javascripts/sidebar/utils.js @@ -1,4 +1,7 @@ -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; +import { STATUS_LABELS } from './constants'; + +export const getStatusLabel = (status) => STATUS_LABELS[status] ?? s__('IncidentManagement|None'); export const todoLabel = (hasTodo) => { return hasTodo ? __('Mark as done') : __('Add a to do'); diff --git a/app/assets/javascripts/snippets/components/snippet_description_view.vue b/app/assets/javascripts/snippets/components/snippet_description_view.vue index 737a131ce7c..ab2ff6e0ef8 100644 --- a/app/assets/javascripts/snippets/components/snippet_description_view.vue +++ b/app/assets/javascripts/snippets/components/snippet_description_view.vue @@ -1,5 +1,5 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue'; export default { diff --git a/app/assets/javascripts/surveys/merge_request_experience/app.vue b/app/assets/javascripts/surveys/merge_request_experience/app.vue index df114c27908..6e90ad2e0fd 100644 --- a/app/assets/javascripts/surveys/merge_request_experience/app.vue +++ b/app/assets/javascripts/surveys/merge_request_experience/app.vue @@ -1,6 +1,7 @@ <script> -import { GlButton, GlSprintf, GlSafeHtmlDirective, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import gitlabLogo from '@gitlab/svgs/dist/illustrations/gitlab_logo.svg'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__, __ } from '~/locale'; import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; import SatisfactionRate from '~/surveys/components/satisfaction_rate.vue'; @@ -30,7 +31,7 @@ export default { SatisfactionRate, }, directives: { - safeHtml: GlSafeHtmlDirective, + SafeHtml, tooltip: GlTooltipDirective, }, mixins: [Tracking.mixin()], diff --git a/app/assets/javascripts/tags/init_new_tag_ref_selector.js b/app/assets/javascripts/tags/init_new_tag_ref_selector.js new file mode 100644 index 00000000000..11c7516f16c --- /dev/null +++ b/app/assets/javascripts/tags/init_new_tag_ref_selector.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import RefSelector from '~/ref/components/ref_selector.vue'; + +export default function initNewTagRefSelector() { + const el = document.querySelector('.js-new-tag-ref-selector'); + + if (el) { + const { projectId, defaultBranchName, hiddenInputName } = el.dataset; + // eslint-disable-next-line no-new + new Vue({ + el, + render(createComponent) { + return createComponent(RefSelector, { + props: { + value: defaultBranchName, + name: hiddenInputName, + projectId, + }, + }); + }, + }); + } +} diff --git a/app/assets/javascripts/terms/components/app.vue b/app/assets/javascripts/terms/components/app.vue index a54a198faed..eecf32f83df 100644 --- a/app/assets/javascripts/terms/components/app.vue +++ b/app/assets/javascripts/terms/components/app.vue @@ -1,13 +1,13 @@ <script> -import $ from 'jquery'; -import { GlButton, GlIntersectionObserver, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlButton, GlIntersectionObserver } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { FLASH_TYPES, FLASH_CLOSED_EVENT } from '~/flash'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import csrf from '~/lib/utils/csrf'; -import '~/behaviors/markdown/render_gfm'; import { trackTrialAcceptTerms } from '~/google_tag_manager'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; export default { name: 'TermsApp', @@ -54,7 +54,7 @@ export default { }, methods: { renderGFM() { - $(this.$refs.gfmContainer).renderGFM(); + renderGFM(this.$refs.gfmContainer); }, handleBottomReached() { this.acceptDisabled = false; @@ -81,7 +81,7 @@ export default { <template> <div> - <div class="gl-card-body gl-relative gl-pb-0 gl-px-0" data-qa-selector="terms_content"> + <div class="gl-relative gl-pb-0 gl-px-0" data-qa-selector="terms_content"> <div class="terms-fade gl-absolute gl-left-5 gl-right-5 gl-bottom-0 gl-h-11 gl-pointer-events-none" ></div> @@ -96,7 +96,7 @@ export default { </gl-intersection-observer> </div> </div> - <div v-if="isLoggedIn" class="gl-card-footer gl-display-flex gl-justify-content-end"> + <div v-if="isLoggedIn" class="gl-display-flex gl-justify-content-end"> <form v-if="permissions.canDecline" method="post" :action="paths.decline"> <gl-button type="submit">{{ $options.i18n.decline }}</gl-button> <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> diff --git a/app/assets/javascripts/terraform/components/init_command_modal.vue b/app/assets/javascripts/terraform/components/init_command_modal.vue index 2cb10d4ae23..0d8a883972f 100644 --- a/app/assets/javascripts/terraform/components/init_command_modal.vue +++ b/app/assets/javascripts/terraform/components/init_command_modal.vue @@ -39,11 +39,13 @@ export default { }, methods: { getModalInfoCopyStr() { + const stateNameEncoded = encodeURIComponent(this.stateName); + return `export GITLAB_ACCESS_TOKEN=<YOUR-ACCESS-TOKEN> terraform init \\ - -backend-config="address=${this.terraformApiUrl}/${this.stateName}" \\ - -backend-config="lock_address=${this.terraformApiUrl}/${this.stateName}/lock" \\ - -backend-config="unlock_address=${this.terraformApiUrl}/${this.stateName}/lock" \\ + -backend-config="address=${this.terraformApiUrl}/${stateNameEncoded}" \\ + -backend-config="lock_address=${this.terraformApiUrl}/${stateNameEncoded}/lock" \\ + -backend-config="unlock_address=${this.terraformApiUrl}/${stateNameEncoded}/lock" \\ -backend-config="username=${this.username}" \\ -backend-config="password=$GITLAB_ACCESS_TOKEN" \\ -backend-config="lock_method=POST" \\ diff --git a/app/assets/javascripts/tooltips/components/tooltips.vue b/app/assets/javascripts/tooltips/components/tooltips.vue index 1ad18508294..a4dc783f1e4 100644 --- a/app/assets/javascripts/tooltips/components/tooltips.vue +++ b/app/assets/javascripts/tooltips/components/tooltips.vue @@ -1,6 +1,7 @@ <script> -import { GlTooltip, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlTooltip } from '@gitlab/ui'; import { uniqueId } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; const getTooltipTitle = (element) => { return element.getAttribute('title') || element.dataset.title; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue index 2cfeb7a4bcb..eb93f42e2f3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue @@ -189,8 +189,11 @@ export default { .then((data) => { this.mr.setApprovals(data); - eventHub.$emit('MRWidgetUpdateRequested'); - eventHub.$emit('ApprovalUpdated'); + if (!window.gon?.features?.realtimeMrStatusChange) { + eventHub.$emit('MRWidgetUpdateRequested'); + eventHub.$emit('ApprovalUpdated'); + } + this.$emit('updated'); }) .catch(errFn) diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue index 1256b3a8e52..c7d34d45f06 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon, GlSprintf, GlLink } from '@gitlab/ui'; import { backOff } from '~/lib/utils/common_utils'; -import statusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status'; import { bytesToMiB } from '~/lib/utils/number_utils'; import { s__ } from '~/locale'; import MemoryGraph from '~/vue_shared/components/memory_graph.vue'; @@ -107,7 +107,7 @@ export default { backOff((next, stop) => { MRWidgetService.fetchMetrics(this.metricsUrl) .then((res) => { - if (res.status === statusCodes.NO_CONTENT) { + if (res.status === HTTP_STATUS_NO_CONTENT) { this.backOffRequestCounter += 1; /* eslint-disable no-unused-expressions */ this.backOffRequestCounter < 3 ? next() : stop(res); @@ -118,7 +118,7 @@ export default { .catch(stop); }) .then((res) => { - if (res.status === statusCodes.NO_CONTENT) { + if (res.status === HTTP_STATUS_NO_CONTENT) { return res; } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue index 3d03dbd9db3..e8cc9b2eb2a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -1,12 +1,7 @@ <script> -import { - GlButton, - GlLoadingIcon, - GlSafeHtmlDirective, - GlTooltipDirective, - GlIntersectionObserver, -} from '@gitlab/ui'; +import { GlButton, GlLoadingIcon, GlTooltipDirective, GlIntersectionObserver } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller'; import { sprintf, s__, __ } from '~/locale'; import Poll from '~/lib/utils/poll'; @@ -40,7 +35,7 @@ export default { StateContainer, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, GlTooltip: GlTooltipDirective, }, data() { @@ -323,19 +318,23 @@ export default { @mouseup="onRowMouseUp" > <div + :class="{ 'gl-h-full': isLoadingSummary }" class="media-body gl-display-flex gl-flex-direction-row! gl-w-full" data-testid="widget-extension-top-level" > - <div class="gl-flex-grow-1" data-testid="widget-extension-top-level-summary"> + <div + class="gl-flex-grow-1 gl-display-flex gl-align-items-center" + data-testid="widget-extension-top-level-summary" + > <template v-if="isLoadingSummary">{{ widgetLoadingText }}</template> <template v-else-if="hasFetchError">{{ widgetErrorText }}</template> - <div v-else> + <template v-else> <span v-safe-html="hydratedSummary.subject"></span> <template v-if="hydratedSummary.meta"> <br /> <span v-safe-html="hydratedSummary.meta" class="gl-font-sm"></span> </template> - </div> + </template> </div> <actions :widget="$options.label || $options.name" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue index a10e5efa0e7..fa369d23b6c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue @@ -1,6 +1,7 @@ <script> -import { GlBadge, GlLink, GlSafeHtmlDirective, GlModalDirective } from '@gitlab/ui'; +import { GlBadge, GlLink, GlModalDirective } from '@gitlab/ui'; import { isArray } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import Actions from '../action_buttons.vue'; import StatusIcon from './status_icon.vue'; import { generateText } from './utils'; @@ -14,7 +15,7 @@ export default { Actions, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, GlModal: GlModalDirective, }, props: { @@ -97,7 +98,12 @@ export default { <div v-if="data.supportingText"> <p v-safe-html="generateText(data.supportingText)" class="gl-m-0"></p> </div> - <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'"> + <gl-badge + v-if="data.badge" + :variant="data.badge.variant || 'info'" + size="sm" + class="gl-ml-2" + > {{ data.badge.text }} </gl-badge> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue index f71b1fbc539..79ea2624ec5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue @@ -1,8 +1,11 @@ <script> -import { GlTooltipDirective } from '@gitlab/ui'; +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; export default { name: 'MrWidgetAuthor', + components: { + GlLink, + }, directives: { GlTooltip: GlTooltipDirective, }, @@ -28,13 +31,16 @@ export default { }; </script> <template> - <a + <gl-link v-gl-tooltip :href="authorUrl" :title="showAuthorName ? null : author.name" - class="author-link inline" + class="mr-widget-author" > - <img :src="avatarUrl" class="avatar avatar-inline s16" /> - <span v-if="showAuthorName" class="author">{{ author.name }}</span> - </a> + <img :src="avatarUrl" :alt="author.name" class="avatar avatar-inline s16" /><span + v-if="showAuthorName" + class="author" + >{{ author.name }}</span + > + </gl-link> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 97c6de37054..d8a361066f4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -7,8 +7,8 @@ import { GlSprintf, GlTooltip, GlTooltipDirective, - GlSafeHtmlDirective, } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__, n__ } from '~/locale'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; @@ -33,7 +33,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { pipeline: { @@ -190,7 +190,7 @@ export default { </template> <template v-else-if="hasPipeline"> <a :href="status.details_path" class="gl-align-self-center gl-mr-3"> - <ci-icon :status="status" :size="24" /> + <ci-icon :status="status" :size="24" class="gl-display-flex" /> </a> <div class="ci-widget-container d-flex"> <div class="ci-widget-content"> @@ -277,9 +277,9 @@ export default { v-if="pipeline.details.stages" :downstream-pipelines="pipeline.triggered" :is-merge-train="isMergeTrain" + :pipeline-path="pipeline.path" :stages="pipeline.details.stages" :upstream-pipeline="pipeline.triggered_by" - stages-class="mr-widget-pipeline-stages" /> <pipeline-artifacts :pipeline-id="pipeline.id" :artifacts="artifacts" class="gl-ml-3" /> </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue index 870972156c5..1fd1e264c25 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue @@ -1,5 +1,6 @@ <script> -import { GlSafeHtmlDirective as SafeHtml, GlLink } from '@gitlab/ui'; +import { GlLink } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__, n__ } from '~/locale'; export default { @@ -54,16 +55,16 @@ export default { </script> <template> <section> - <p v-if="relatedLinks.closing" class="gl-display-inline gl-m-0"> + <p v-if="relatedLinks.closing" class="gl-display-inline gl-m-0 gl-font-sm!"> {{ closesText }} <span v-safe-html="relatedLinks.closing"></span> </p> - <p v-if="relatedLinks.mentioned" class="gl-display-inline gl-m-0"> + <p v-if="relatedLinks.mentioned" class="gl-display-inline gl-m-0 gl-font-sm!"> <span v-if="relatedLinks.closing">·</span> {{ n__('mrWidget|Mentions issue', 'mrWidget|Mentions issues', relatedLinks.mentionedCount) }} <span v-safe-html="relatedLinks.mentioned"></span> </p> - <p v-if="shouldShowAssignToMeLink" class="gl-display-inline gl-m-0"> + <p v-if="shouldShowAssignToMeLink" class="gl-display-inline gl-m-0 gl-font-sm!"> <span> <gl-link rel="nofollow" data-method="post" :href="relatedLinks.assignToMe">{{ assignIssueText diff --git a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue index 66e33a08a12..9a3555d3e11 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue @@ -54,7 +54,7 @@ export default { <template> <div - class="mr-widget-body media mr-widget-body-line-height-1 gl-line-height-normal" + class="mr-widget-body media gl-display-flex gl-align-items-center" :class="wrapperClasses" v-on="$listeners" > diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue index 38b99dae264..e5688091cc7 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue @@ -1,6 +1,6 @@ <script> import { s__ } from '~/locale'; -import StatusIcon from '../mr_widget_status_icon.vue'; +import StateContainer from '../state_container.vue'; import { DETAILED_MERGE_STATUS } from '../../constants'; export default { @@ -12,7 +12,7 @@ export default { externalStatusChecksFailed: s__('mrWidget|Merge blocked: all status checks must pass.'), }, components: { - StatusIcon, + StateContainer, }, props: { mr: { @@ -37,10 +37,11 @@ export default { </script> <template> - <div class="mr-widget-body media gl-flex-wrap"> - <status-icon status="failed" /> - <p class="media-body gl-m-0! gl-font-weight-bold gl-text-black-normal!"> + <state-container :mr="mr" status="failed"> + <span + class="gl-ml-3 gl-font-weight-bold gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!" + > {{ failedText }} - </p> - </div> + </span> + </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue index 806f8f939a6..6bcf88713a5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue @@ -1,7 +1,17 @@ <script> +import api from '~/api'; +import showGlobalToast from '~/vue_shared/plugins/global_toast'; + import MrWidgetAuthorTime from '../mr_widget_author_time.vue'; import StateContainer from '../state_container.vue'; +import { + MR_WIDGET_CLOSED_REOPEN, + MR_WIDGET_CLOSED_REOPENING, + MR_WIDGET_CLOSED_RELOADING, + MR_WIDGET_CLOSED_REOPEN_FAILURE, +} from '../../i18n'; + export default { name: 'MRWidgetClosed', components: { @@ -14,10 +24,62 @@ export default { required: true, }, }, + data() { + return { + isPending: false, + isReloading: false, + }; + }, + computed: { + reopenText() { + let text = MR_WIDGET_CLOSED_REOPEN; + + if (this.isPending) { + text = MR_WIDGET_CLOSED_REOPENING; + } else if (this.isReloading) { + text = MR_WIDGET_CLOSED_RELOADING; + } + + return text; + }, + actions() { + if (!window.gon?.current_user_id) { + return []; + } + + return [ + { + text: this.reopenText, + loading: this.isPending || this.isReloading, + onClick: this.reopen, + testId: 'extension-actions-reopen-button', + }, + ]; + }, + }, + methods: { + reopen() { + this.isPending = true; + + api + .updateMergeRequest(this.mr.targetProjectId, this.mr.iid, { state_event: 'reopen' }) + .then(() => { + this.isReloading = true; + + window.location.reload(); + }) + .catch(() => { + showGlobalToast(MR_WIDGET_CLOSED_REOPEN_FAILURE); + }) + .finally(() => { + this.isPending = false; + }); + }, + }, }; </script> <template> - <state-container :mr="mr" status="closed"> + <state-container :mr="mr" status="closed" :actions="actions"> <mr-widget-author-time :action-text="s__('mrWidget|Closed by')" :author="mr.metrics.closedBy" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue index 4902c9b45e8..850a4e2fd56 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue @@ -1,5 +1,6 @@ <script> -import { GlButton, GlSprintf, GlLink, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlButton, GlSprintf, GlLink } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import emptyStateSVG from 'icons/_mr_widget_empty_state.svg'; import api from '~/api'; import { helpPagePath } from '~/helpers/help_page_helper'; @@ -12,7 +13,7 @@ export default { GlLink, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { mr: { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index c54672cd0f8..23b163e2c6a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -20,6 +20,8 @@ import simplePoll from '~/lib/utils/simple_poll'; import { __, s__, n__ } from '~/locale'; import SmartInterval from '~/smart_interval'; import { helpPagePath } from '~/helpers/help_page_helper'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import readyToMergeSubscription from '~/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql'; import { AUTO_MERGE_STRATEGIES, WARNING, @@ -87,6 +89,31 @@ export default { this.initPolling(); } }, + subscribeToMore: { + document() { + return readyToMergeSubscription; + }, + skip() { + return !this.mr?.id || this.loading || !window.gon?.features?.realtimeMrStatusChange; + }, + variables() { + return { + issuableId: convertToGraphQLId('MergeRequest', this.mr?.id), + }; + }, + updateQuery( + _, + { + subscriptionData: { + data: { mergeRequestMergeStatusUpdated }, + }, + }, + ) { + if (mergeRequestMergeStatusUpdated) { + this.state = mergeRequestMergeStatusUpdated; + } + }, + }, }, }, components: { @@ -295,7 +322,7 @@ export default { return this.mr.divergedCommitsCount > 0; }, showMergeDetailsHeader() { - return ['readyToMerge'].indexOf(this.mr.state) >= 0; + return !['readyToMerge'].includes(this.mr.state); }, }, mounted() { @@ -467,8 +494,9 @@ export default { <template> <div + :class="{ 'gl-bg-gray-10': mr.state !== 'closed' && mr.state !== 'merged' }" data-testid="ready_to_merge_state" - class="gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-pl-7" + class="gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-pl-7" > <div v-if="loading" class="mr-widget-body"> <div class="gl-w-full mr-ready-to-merge-loader"> @@ -481,7 +509,9 @@ export default { </div> </div> <template v-else> - <div class="mr-widget-body mr-widget-body-ready-merge media mr-widget-body-line-height-1"> + <div + class="mr-widget-body mr-widget-body-ready-merge media gl-display-flex gl-align-items-center" + > <div class="media-body"> <div class="mr-widget-body-controls gl-display-flex gl-align-items-center gl-flex-wrap"> <template v-if="shouldShowMergeControls"> @@ -555,7 +585,19 @@ export default { </li> </ul> </div> - <div class="gl-w-full gl-text-gray-500 gl-mb-3 gl-md-mb-0 gl-md-pb-5"> + <div + class="gl-w-full gl-text-gray-500 gl-mb-3 gl-md-mb-0 gl-md-pb-5 mr-widget-merge-details" + > + <template v-if="sourceHasDivergedFromTarget"> + <gl-sprintf :message="$options.i18n.sourceDivergedFromTargetText"> + <template #link> + <gl-link :href="mr.targetBranchPath">{{ + $options.i18n.divergedCommits(mr.divergedCommitsCount) + }}</gl-link> + </template> + </gl-sprintf> + · + </template> <added-commit-message :is-squash-enabled="squashBeforeMerge" :is-fast-forward-enabled="!shouldShowMergeEdit" @@ -631,7 +673,7 @@ export default { class="gl-w-full gl-order-n1 mr-widget-merge-details" data-qa-selector="merged_status_content" > - <p v-if="showMergeDetailsHeader" class="gl-mb-3 gl-text-gray-900"> + <p v-if="showMergeDetailsHeader" class="gl-mb-2 gl-text-gray-900"> {{ __('Merge details') }} </p> <ul class="gl-pl-4 gl-mb-0 gl-ml-3 gl-text-gray-600"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue index 074758e33b2..9f3748599dc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue @@ -26,7 +26,7 @@ export default { <template> <state-container :mr="mr" status="failed"> <span - class="gl-ml-3 gl-font-weight-bold gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body! gl-align-self-start" + class="gl-ml-3 gl-font-weight-bold gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!" > {{ s__('mrWidget|Merge blocked: all threads must be resolved.') }} </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue index ef5be0fbfcd..01f9b4757a0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue @@ -94,6 +94,7 @@ export default { errors: [], mergeRequest: { __typename: 'MergeRequest', + id: this.mr.issuableId, mergeableDiscussionsState: true, title: this.mr.title, draft: false, @@ -111,7 +112,10 @@ export default { }) => { toast(__('Marked as ready. Merging is now allowed.')); $('.merge-request .detail-page-description .title').text(title); - eventHub.$emit('MRWidgetUpdateRequested'); + + if (!window.gon?.features?.realtimeMrStatusChange) { + eventHub.$emit('MRWidgetUpdateRequested'); + } }, ) .catch(() => diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue new file mode 100644 index 00000000000..6655af92a55 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue @@ -0,0 +1,134 @@ +<script> +import { GlButton, GlDropdown, GlDropdownItem, GlTooltipDirective } from '@gitlab/ui'; +import { sprintf, __ } from '~/locale'; + +export default { + components: { + GlButton, + GlDropdown, + GlDropdownItem, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + widget: { + type: String, + required: false, + default: '', + }, + tertiaryButtons: { + type: Array, + required: false, + default: () => [], + }, + }, + data: () => { + return { + timeout: null, + updatingTooltip: false, + }; + }, + computed: { + dropdownLabel() { + if (!this.widget) return undefined; + + return sprintf(__('%{widget} options'), { widget: this.widget }); + }, + }, + methods: { + onClickAction(action) { + this.$emit('clickedAction', action); + + if (action.onClick) { + action.onClick(); + } + + if (action.tooltipOnClick) { + this.updatingTooltip = true; + this.$root.$emit('bv::show::tooltip', action.id); + + clearTimeout(this.timeout); + + this.timeout = setTimeout(() => { + this.updatingTooltip = false; + this.$root.$emit('bv::hide::tooltip', action.id); + }, 1000); + } + }, + setTooltip(btn) { + if (this.updatingTooltip && btn.tooltipOnClick) { + return btn.tooltipOnClick; + } + + return btn.tooltipText; + }, + actionButtonQaSelector(btn) { + if (btn.dataQaSelector) { + return btn.dataQaSelector; + } + return 'mr_widget_extension_actions_button'; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-flex-start"> + <gl-dropdown + v-if="tertiaryButtons.length" + v-gl-tooltip + :title="__('Options')" + :text="dropdownLabel" + icon="ellipsis_v" + no-caret + category="tertiary" + right + lazy + text-sr-only + size="small" + toggle-class="gl-p-2!" + class="gl-display-block gl-md-display-none!" + > + <gl-dropdown-item + v-for="(btn, index) in tertiaryButtons" + :key="index" + :href="btn.href" + :target="btn.target" + :data-clipboard-text="btn.dataClipboardText" + :data-method="btn.dataMethod" + @click="onClickAction(btn)" + > + {{ btn.text }} + </gl-dropdown-item> + </gl-dropdown> + <template v-if="tertiaryButtons.length"> + <gl-button + v-for="(btn, index) in tertiaryButtons" + :id="btn.id" + :key="index" + v-gl-tooltip.hover + :title="setTooltip(btn)" + :href="btn.href" + :target="btn.target" + :class="[{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }, btn.class]" + :data-clipboard-text="btn.dataClipboardText" + :data-qa-selector="actionButtonQaSelector(btn)" + :data-method="btn.dataMethod" + :icon="btn.icon" + :data-testid="btn.testId || 'extension-actions-button'" + :variant="btn.variant || 'confirm'" + :loading="btn.loading" + :disabled="btn.loading" + category="tertiary" + size="small" + class="gl-display-none gl-md-display-block gl-float-left" + @click="onClickAction(btn)" + > + <template v-if="btn.text"> + {{ btn.text }} + </template> + </gl-button> + </template> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue index 2f52ac70833..18aa85484ea 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue @@ -20,13 +20,14 @@ export default { role="region" :aria-label="__('Merge request reports')" data-testid="mr-widget-app" + class="mr-widget-section" > <component :is="widget" v-for="(widget, index) in widgets" :key="widget.name || index" :mr="mr" - :class="{ 'mr-widget-border-top': index === 0 }" + class="mr-widget-section" /> </section> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue index 4d66c75719b..cdce7c6625a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue @@ -1,8 +1,9 @@ <script> -import { GlBadge, GlLink, GlSafeHtmlDirective } from '@gitlab/ui'; -import Actions from '../action_buttons.vue'; +import { GlBadge, GlLink } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { generateText } from '../extensions/utils'; import ContentRow from './widget_content_row.vue'; +import Actions from './action_buttons.vue'; export default { name: 'DynamicContent', @@ -13,7 +14,7 @@ export default { ContentRow, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { data: { @@ -81,10 +82,8 @@ export default { v-if="data.children && data.children.length > 0 && level === 2" class="gl-m-0 gl-p-0 gl-list-style-none" > - <li> + <li v-for="(childData, index) in data.children" :key="childData.id || index"> <dynamic-content - v-for="(childData, index) in data.children" - :key="childData.id || index" :data="childData" :widget-name="widgetName" :level="3" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue index 181b8cfad9a..6d17ac98d7f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue @@ -48,9 +48,9 @@ export default { :class="{ [iconClassNameText]: !isLoading, [`mr-widget-status-icon-level-${level}`]: !isLoading, - 'gl-mr-3': level === 1, + 'gl-w-6 gl-h-6 gl--flex-center': level === 1, }" - class="gl-relative gl-w-6 gl-h-6 gl-rounded-full gl--flex-center" + class="gl-relative gl-rounded-full gl-mr-3" > <gl-loading-icon v-if="isLoading" size="md" inline /> <gl-icon diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue index cea7fb8260a..cdf35033021 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue @@ -1,22 +1,18 @@ <script> -import { - GlButton, - GlLink, - GlTooltipDirective, - GlLoadingIcon, - GlSafeHtmlDirective, -} from '@gitlab/ui'; +import { GlButton, GlLink, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { normalizeHeaders } from '~/lib/utils/common_utils'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { sprintf, __ } from '~/locale'; import Poll from '~/lib/utils/poll'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; -import ActionButtons from '../action_buttons.vue'; +import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller'; import { EXTENSION_ICONS } from '../../constants'; import { createTelemetryHub } from '../extensions/telemetry'; import ContentRow from './widget_content_row.vue'; import DynamicContent from './dynamic_content.vue'; import StatusIcon from './status_icon.vue'; +import ActionButtons from './action_buttons.vue'; const FETCH_TYPE_COLLAPSED = 'collapsed'; const FETCH_TYPE_EXPANDED = 'expanded'; @@ -31,11 +27,13 @@ export default { GlLoadingIcon, ContentRow, DynamicContent, + DynamicScroller, + DynamicScrollerItem, HelpPopover, }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { /** @@ -258,6 +256,7 @@ export default { <div class="gl-display-flex"> <help-popover v-if="helpPopover" + icon="information-o" :options="helpPopover.options" :class="{ 'gl-mr-3': actionButtons.length > 0 }" > @@ -309,7 +308,7 @@ export default { <div v-if="isLoadingExpandedContent" class="report-block-container gl-text-center"> <gl-loading-icon size="sm" inline /> {{ loadingText }} </div> - <div v-else class="gl-px-5 gl-display-flex"> + <div v-else class="gl-pl-5 gl-display-flex" :class="{ 'gl-pr-5': $scopedSlots.content }"> <content-row v-if="contentError" :level="2" @@ -322,12 +321,25 @@ export default { </content-row> <div v-else class="gl-w-full"> <slot name="content"> - <dynamic-content - v-for="(data, index) in content" - :key="data.id || index" - :data="data" - :widget-name="widgetName" - /> + <dynamic-scroller + v-if="content" + :items="content" + :min-item-size="32" + :style="{ maxHeight: '170px' }" + data-testid="dynamic-content-scroller" + class="gl-pr-5" + > + <template #default="{ item, index, active }"> + <dynamic-scroller-item :item="item" :active="active"> + <dynamic-content + :key="item.id || index" + :data="item" + :widget-name="widgetName" + :level="2" + /> + </dynamic-scroller-item> + </template> + </dynamic-scroller> </slot> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue index 1fd1e325863..543136dc659 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue @@ -1,10 +1,11 @@ <script> -import { GlSafeHtmlDirective, GlLink } from '@gitlab/ui'; +import { GlLink } from '@gitlab/ui'; import { __ } from '~/locale'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; -import ActionButtons from '../action_buttons.vue'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { EXTENSION_ICONS } from '../../constants'; import { generateText } from '../extensions/utils'; +import ActionButtons from './action_buttons.vue'; import StatusIcon from './status_icon.vue'; export default { @@ -15,7 +16,7 @@ export default { ActionButtons, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { level: { @@ -67,6 +68,9 @@ export default { shouldShowHeaderActions() { return Boolean(this.helpPopover) || this.actionButtons?.length > 0; }, + hasActionButtons() { + return this.actionButtons.length > 0; + }, }, i18n: { learnMore: __('Learn more'), @@ -75,10 +79,15 @@ export default { </script> <template> <div - class="gl-w-full gl-display-flex mr-widget-content-row gl-align-items-baseline" + class="gl-w-full gl-display-flex gl-align-items-baseline" :class="{ 'gl-border-t gl-py-3 gl-pl-7': level === 2 }" > - <status-icon v-if="statusIconName" :level="2" :name="widgetName" :icon-name="statusIconName" /> + <status-icon + v-if="statusIconName && !header" + :level="2" + :name="widgetName" + :icon-name="statusIconName" + /> <div class="gl-w-full"> <div class="gl-display-flex"> <slot name="header"> @@ -95,7 +104,12 @@ export default { v-if="shouldShowHeaderActions" class="gl-ml-auto gl-display-flex gl-align-items-baseline" > - <help-popover v-if="helpPopover" :options="helpPopover.options"> + <help-popover + v-if="helpPopover" + :options="helpPopover.options" + :class="{ 'gl-mr-3': hasActionButtons }" + icon="information-o" + > <template v-if="helpPopover.content"> <p v-if="helpPopover.content.text" @@ -112,14 +126,19 @@ export default { </template> </help-popover> <action-buttons - v-if="actionButtons.length > 0" + v-if="hasActionButtons" :widget="widgetName" :tertiary-buttons="actionButtons" - :class="{ 'gl-ml-2': helpPopover }" /> </div> </div> <div class="gl-display-flex gl-align-items-baseline gl-w-full"> + <status-icon + v-if="statusIconName && header" + :level="2" + :name="widgetName" + :icon-name="statusIconName" + /> <slot name="body"></slot> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/constants.js b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/constants.js new file mode 100644 index 00000000000..03af21a5019 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/constants.js @@ -0,0 +1,31 @@ +import { n__, s__, sprintf } from '~/locale'; + +export const i18n = { + label: s__('ciReport|Code Quality'), + loading: s__('ciReport|Code Quality is loading'), + error: s__('ciReport|Code Quality failed to load results'), + noChanges: s__(`ciReport|Code Quality hasn't changed.`), + prependText: s__(`ciReport|in`), + fixed: s__(`ciReport|Fixed`), + pluralReport: (errors) => + sprintf( + n__( + '%{strong_start}%{errors}%{strong_end} point', + '%{strong_start}%{errors}%{strong_end} points', + errors.length, + ), + { + errors: errors.length, + }, + false, + ), + singularReport: (errors) => n__('%d point', '%d points', errors.length), + improvementAndDegradationCopy: (improvement, degradation) => + sprintf( + s__(`ciReport|Code Quality improved on ${improvement} and degraded on ${degradation}.`), + ), + improvedCopy: (improvements) => + sprintf(s__(`ciReport|Code Quality improved on ${improvements}.`)), + degradedCopy: (degradations) => + sprintf(s__(`ciReport|Code Quality degraded on ${degradations}.`)), +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js index 68347ac269e..394f8979a53 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js @@ -1,54 +1,33 @@ -import { n__, s__, sprintf } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants'; -import { SEVERITY_ICONS_EXTENSION } from '~/reports/codequality_report/constants'; -import { parseCodeclimateMetrics } from '~/reports/codequality_report/store/utils/codequality_parser'; +import { SEVERITY_ICONS_MR_WIDGET } from '~/ci/reports/codequality_report/constants'; +import { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status'; +import { parseCodeclimateMetrics } from '~/ci/reports/codequality_report/store/utils/codequality_parser'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { i18n } from './constants'; export default { name: 'WidgetCodeQuality', + enablePolling: true, props: ['codeQuality', 'blobPath'], - i18n: { - label: s__('ciReport|Code Quality'), - loading: s__('ciReport|Code Quality test metrics results are being parsed'), - error: s__('ciReport|Code Quality failed loading results'), - }, + i18n, computed: { - summary() { - const { newErrors, resolvedErrors, errorSummary } = this.collapsedData; - if (errorSummary.errored >= 1 && errorSummary.resolved >= 1) { - const improvements = sprintf( - n__( - '%{strong_start}%{errors}%{strong_end} point', - '%{strong_start}%{errors}%{strong_end} points', - resolvedErrors.length, - ), - { - errors: resolvedErrors.length, - }, - false, - ); + summary(data) { + const { newErrors, resolvedErrors, errorSummary, parsingInProgress } = data; - const degradations = sprintf( - n__( - '%{strong_start}%{errors}%{strong_end} point', - '%{strong_start}%{errors}%{strong_end} points', - newErrors.length, - ), - { errors: newErrors.length }, - false, - ); - return sprintf( - s__(`ciReport|Code Quality improved on ${improvements} and degraded on ${degradations}.`), + if (parsingInProgress) { + return i18n.loading; + } else if (errorSummary.errored >= 1 && errorSummary.resolved >= 1) { + return i18n.improvementAndDegradationCopy( + i18n.pluralReport(resolvedErrors), + i18n.pluralReport(newErrors), ); } else if (errorSummary.resolved >= 1) { - const improvements = n__('%d point', '%d points', resolvedErrors.length); - return sprintf(s__(`ciReport|Code Quality improved on ${improvements}.`)); + return i18n.improvedCopy(i18n.singularReport(resolvedErrors)); } else if (errorSummary.errored >= 1) { - const degradations = n__('%d point', '%d points', newErrors.length); - return sprintf(s__(`ciReport|Code Quality degraded on ${degradations}.`)); + return i18n.degradedCopy(i18n.singularReport(newErrors)); } - return s__(`ciReport|No changes to Code Quality.`); + return i18n.noChanges; }, statusIcon() { if (this.collapsedData.errorSummary?.errored >= 1) { @@ -59,18 +38,17 @@ export default { }, methods: { fetchCollapsedData() { - return Promise.all([this.fetchReport(this.codeQuality)]).then((values) => { + return axios.get(this.codeQuality).then((response) => { + const { data = {}, status } = response; return { - resolvedErrors: parseCodeclimateMetrics( - values[0].resolved_errors, - this.blobPath.head_path, - ), - newErrors: parseCodeclimateMetrics(values[0].new_errors, this.blobPath.head_path), - existingErrors: parseCodeclimateMetrics( - values[0].existing_errors, - this.blobPath.head_path, - ), - errorSummary: values[0].summary, + ...response, + data: { + parsingInProgress: status === HTTP_STATUS_NO_CONTENT, + resolvedErrors: parseCodeclimateMetrics(data.resolved_errors, this.blobPath.head_path), + newErrors: parseCodeclimateMetrics(data.new_errors, this.blobPath.head_path), + existingErrors: parseCodeclimateMetrics(data.existing_errors, this.blobPath.head_path), + errorSummary: data.summary, + }, }; }); }, @@ -81,12 +59,12 @@ export default { return fullData.push({ text: `${capitalizeFirstCharacter(e.severity)} - ${e.description}`, subtext: { - prependText: s__(`ciReport|in`), + prependText: i18n.prependText, text: `${e.file_path}:${e.line}`, href: e.urlPath, }, icon: { - name: SEVERITY_ICONS_EXTENSION[e.severity], + name: SEVERITY_ICONS_MR_WIDGET[e.severity], }, }); }); @@ -95,12 +73,16 @@ export default { return fullData.push({ text: `${capitalizeFirstCharacter(e.severity)} - ${e.description}`, subtext: { - prependText: s__(`ciReport|in`), + prependText: i18n.prependText, text: `${e.file_path}:${e.line}`, href: e.urlPath, }, icon: { - name: SEVERITY_ICONS_EXTENSION[e.severity], + name: SEVERITY_ICONS_MR_WIDGET[e.severity], + }, + badge: { + variant: 'neutral', + text: i18n.fixed, }, }); }); diff --git a/app/assets/javascripts/vue_merge_request_widget/i18n.js b/app/assets/javascripts/vue_merge_request_widget/i18n.js index 454a14faabb..5380bcae003 100644 --- a/app/assets/javascripts/vue_merge_request_widget/i18n.js +++ b/app/assets/javascripts/vue_merge_request_widget/i18n.js @@ -25,3 +25,10 @@ export const MERGE_TRAIN_BUTTON_TEXT = { failed: __('Start merge train...'), passed: __('Start merge train'), }; + +export const MR_WIDGET_CLOSED_REOPEN = __('Reopen'); +export const MR_WIDGET_CLOSED_REOPENING = __('Reopening...'); +export const MR_WIDGET_CLOSED_RELOADING = __('Refreshing...'); +export const MR_WIDGET_CLOSED_REOPEN_FAILURE = __( + 'An error occurred. Unable to reopen this merge request.', +); 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 b96bdcb3833..00024a594dc 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 @@ -1,10 +1,10 @@ <script> -import { GlSafeHtmlDirective } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { registerExtension, registeredExtensions, } from '~/vue_merge_request_widget/components/extensions'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue'; import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service'; import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store'; @@ -15,6 +15,7 @@ import notify from '~/lib/utils/notify'; import { sprintf, s__, __ } from '~/locale'; import Project from '~/pages/projects/project'; import SmartInterval from '~/smart_interval'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; import { setFaviconOverlay } from '../lib/utils/favicon'; import Loading from './components/loading.vue'; import MrWidgetAlertMessage from './components/mr_widget_alert_message.vue'; @@ -46,18 +47,20 @@ import { STATE_MACHINE, stateToComponentMap } from './constants'; import eventHub from './event_hub'; import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables'; import getStateQuery from './queries/get_state.query.graphql'; +import getStateSubscription from './queries/get_state.subscription.graphql'; import terraformExtension from './extensions/terraform'; import accessibilityExtension from './extensions/accessibility'; import codeQualityExtension from './extensions/code_quality'; import testReportExtension from './extensions/test_report'; import ReportWidgetContainer from './components/report_widget_container.vue'; +import MrWidgetReadyToMerge from './components/states/new_ready_to_merge.vue'; export default { // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25 // eslint-disable-next-line @gitlab/require-i18n-strings name: 'MRWidget', directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, components: { Loading, @@ -76,7 +79,7 @@ export default { MrWidgetNothingToMerge: NothingToMergeState, MrWidgetNotAllowed: NotAllowedState, MrWidgetMissingBranch: MissingBranchState, - MrWidgetReadyToMerge: () => import('./components/states/new_ready_to_merge.vue'), + MrWidgetReadyToMerge, ShaMismatch, MrWidgetChecking: CheckingState, MrWidgetUnresolvedDiscussions: UnresolvedDiscussionsState, @@ -108,6 +111,31 @@ export default { this.loading = false; } }, + subscribeToMore: { + document() { + return getStateSubscription; + }, + skip() { + return !this.mr?.id || this.loading || !window.gon?.features?.realtimeMrStatusChange; + }, + variables() { + return { + issuableId: convertToGraphQLId('MergeRequest', this.mr?.id), + }; + }, + updateQuery( + _, + { + subscriptionData: { + data: { mergeRequestMergeStatusUpdated }, + }, + }, + ) { + if (mergeRequestMergeStatusUpdated) { + this.mr.setGraphqlSubscriptionData(mergeRequestMergeStatusUpdated); + } + }, + }, }, }, mixins: [mergeRequestQueryVariablesMixin], @@ -128,6 +156,7 @@ export default { machineState: store?.machineValue || STATE_MACHINE.definition.initial, loading: true, recomputeComponentName: 0, + issuableId: false, }; }, computed: { @@ -545,6 +574,7 @@ export default { <mr-widget-approvals v-if="shouldRenderApprovals" :mr="mr" :service="service" /> <report-widget-container> <extensions-container v-if="hasExtensions" :mr="mr" /> + <widget-container v-if="mr && shouldShowSecurityExtension" :mr="mr" /> <security-reports-app v-if="shouldRenderSecurityReport && !shouldShowSecurityExtension" :pipeline-id="mr.pipeline.id" @@ -580,8 +610,6 @@ export default { </mr-widget-alert-message> </div> - <widget-container v-if="mr" :mr="mr" /> - <div class="mr-widget-section" data-qa-selector="mr_widget_content"> <component :is="componentName" :mr="mr" :service="service" /> <ready-to-merge diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql new file mode 100644 index 00000000000..c7b53db1221 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql @@ -0,0 +1,7 @@ +subscription getStateSubscription($issuableId: IssuableID!) { + mergeRequestMergeStatusUpdated(issuableId: $issuableId) { + ... on MergeRequest { + detailedMergeStatus + } + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql index 54770e6579a..9b0420cc7fa 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql @@ -1,44 +1,11 @@ +#import "./ready_to_merge_merge_request.fragment.graphql" + fragment ReadyToMerge on Project { id onlyAllowMergeIfPipelineSucceeds mergeRequestsFfOnlyEnabled squashReadOnly mergeRequest(iid: $iid) { - id - autoMergeEnabled - shouldRemoveSourceBranch - forceRemoveSourceBranch - defaultMergeCommitMessage - defaultSquashCommitMessage - squash - squashOnMerge - availableAutoMergeStrategies - hasCi - mergeable - mergeWhenPipelineSucceeds - commitCount - diffHeadSha - userPermissions { - canMerge - removeSourceBranch - updateMergeRequest - } - targetBranch - mergeError - commitsWithoutMergeCommits { - nodes { - id - sha - shortId - title - message - } - } - headPipeline { - id - status - path - active - } + ...ReadyToMergeMergeRequest } } diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql new file mode 100644 index 00000000000..8aba172e09c --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql @@ -0,0 +1,9 @@ +#import "./ready_to_merge_merge_request.fragment.graphql" + +subscription readyToMergeSubscription($issuableId: IssuableID!) { + mergeRequestMergeStatusUpdated(issuableId: $issuableId) { + ... on MergeRequest { + ...ReadyToMergeMergeRequest + } + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge_merge_request.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge_merge_request.fragment.graphql new file mode 100644 index 00000000000..276e2d4d63f --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge_merge_request.fragment.graphql @@ -0,0 +1,39 @@ +fragment ReadyToMergeMergeRequest on MergeRequest { + id + detailedMergeStatus + autoMergeEnabled + shouldRemoveSourceBranch + forceRemoveSourceBranch + defaultMergeCommitMessage + defaultSquashCommitMessage + squash + squashOnMerge + availableAutoMergeStrategies + hasCi + mergeable + mergeWhenPipelineSucceeds + commitCount + diffHeadSha + userPermissions { + canMerge + removeSourceBranch + updateMergeRequest + } + targetBranch + mergeError + commitsWithoutMergeCommits { + nodes { + id + sha + shortId + title + message + } + } + headPipeline { + id + status + path + active + } +} 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 86ce032ea3d..85df2ea63c8 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 @@ -30,6 +30,7 @@ export default class MergeRequestStore { this.machineValue = this.stateMachine.value; this.mergeDetailsCollapsed = window.innerWidth < 768; this.mergeError = data.mergeError; + this.id = data.id; this.setPaths(data); @@ -177,6 +178,7 @@ export default class MergeRequestStore { this.updateStatusState(mergeRequest.state); + this.issuableId = mergeRequest.id; this.projectArchived = project.archived; this.onlyAllowMergeIfPipelineSucceeds = project.onlyAllowMergeIfPipelineSucceeds; this.allowMergeOnSkippedPipeline = project.allowMergeOnSkippedPipeline; @@ -206,6 +208,12 @@ export default class MergeRequestStore { this.setState(); } + setGraphqlSubscriptionData(data) { + this.detailedMergeStatus = data.detailedMergeStatus; + + this.setState(); + } + updateStatusState(state) { if (this.mergeRequestState !== state && badgeState.updateStatus) { badgeState.updateStatus(); diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue index 96c2ffa929c..6803d609dbc 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue @@ -9,9 +9,9 @@ import { GlTabs, GlTab, GlButton, - GlSafeHtmlDirective, } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; import { fetchPolicies } from '~/lib/graphql'; import { toggleContainerClasses } from '~/lib/utils/dom_utils'; @@ -41,7 +41,7 @@ export default { reportedAtWithTool: s__('AlertManagement|Reported %{when} by %{tool}'), }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, severityLabels: SEVERITY_LEVELS, tabsConfig: [ @@ -369,10 +369,10 @@ export default { <alert-details-table :alert="alert" :loading="loading" :statuses="statuses" /> </gl-tab> - <metric-images-tab - :data-testid="$options.tabsConfig[1].id" - :title="$options.tabsConfig[1].title" - /> + <gl-tab :title="$options.tabsConfig[1].title"> + <metric-images-tab :data-testid="$options.tabsConfig[1].id" /> + </gl-tab> + <gl-tab :data-testid="$options.tabsConfig[2].id" :title="$options.tabsConfig[2].title"> <div v-if="alert.notes.nodes.length > 0" class="issuable-discussion"> <ul class="notes main-notes-list timeline"> diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue index 672761af1cf..8d2ef20b381 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue @@ -106,7 +106,7 @@ export default { @keydown.esc.native="$emit('hide-dropdown')" @hide="$emit('hide-dropdown')" > - <p v-if="isSidebar" class="gl-new-dropdown-header-top" data-testid="dropdown-header"> + <p v-if="isSidebar" class="gl-dropdown-header-top" data-testid="dropdown-header"> {{ s__('AlertManagement|Assign status') }} </p> <div class="dropdown-content dropdown-body"> diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue index 72dcc16b57a..4ec301b946b 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue @@ -242,7 +242,7 @@ export default { @keydown.esc.native="hideDropdown" @hide="hideDropdown" > - <p class="gl-new-dropdown-header-top"> + <p class="gl-dropdown-header-top"> {{ __('Assign To') }} </p> <gl-search-box-by-type v-model.trim="search" :placeholder="__('Search users')" /> diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue index 832b154b312..b3ee01f3a24 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue @@ -1,5 +1,5 @@ <script> -import ToggleSidebar from '~/vue_shared/components/sidebar/toggle_sidebar.vue'; +import ToggleSidebar from '~/sidebar/components/toggle/toggle_sidebar.vue'; import SidebarTodo from './sidebar_todo.vue'; export default { diff --git a/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue index 6b774b2a734..3c73f42b6b1 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue @@ -1,5 +1,6 @@ <script> -import { GlIcon, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlIcon } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import NoteHeader from '~/notes/components/note_header.vue'; export default { @@ -8,7 +9,7 @@ export default { GlIcon, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { note: { diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql index 33091f1ba5e..b04d5773a37 100644 --- a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql +++ b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql @@ -8,6 +8,7 @@ mutation alertSetAssignees($fullPath: ID!, $assigneeUsernames: [String!]!, $iid: ) { errors issuable: alert { + id iid assignees { nodes { diff --git a/app/assets/javascripts/vue_shared/components/actions_button.vue b/app/assets/javascripts/vue_shared/components/actions_button.vue index c6c22f9c61f..175aef59ae5 100644 --- a/app/assets/javascripts/vue_shared/components/actions_button.vue +++ b/app/assets/javascripts/vue_shared/components/actions_button.vue @@ -1,11 +1,5 @@ <script> -import { - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - GlButton, - GlTooltipDirective, -} from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlButton, GlTooltip } from '@gitlab/ui'; export default { components: { @@ -13,11 +7,14 @@ export default { GlDropdownItem, GlDropdownDivider, GlButton, - }, - directives: { - GlTooltip: GlTooltipDirective, + GlTooltip, }, props: { + id: { + type: String, + required: false, + default: '', + }, actions: { type: Array, required: true, @@ -37,6 +34,11 @@ export default { required: false, default: 'default', }, + showActionTooltip: { + type: Boolean, + required: false, + default: true, + }, }, computed: { hasMultipleActions() { @@ -51,6 +53,7 @@ export default { this.$emit('select', action.key); }, handleClick(action, evt) { + this.$emit('actionClicked', { action }); return action.handle?.(evt); }, }, @@ -58,46 +61,51 @@ export default { </script> <template> - <gl-dropdown - v-if="hasMultipleActions" - v-gl-tooltip="selectedAction.tooltip" - :text="selectedAction.text" - :split-href="selectedAction.href" - :variant="variant" - :category="category" - split - data-qa-selector="action_dropdown" - @click="handleClick(selectedAction, $event)" - > - <template #button-content> - <span class="gl-new-dropdown-button-text" v-bind="selectedAction.attrs"> - {{ selectedAction.text }} - </span> - </template> - <template v-for="(action, index) in actions"> - <gl-dropdown-item - :key="action.key" - is-check-item - :is-checked="action.key === selectedAction.key" - :secondary-text="action.secondaryText" - :data-qa-selector="`${action.key}_menu_item`" - :data-testid="`action_${action.key}`" - @click="handleItemClick(action)" - > - <span class="gl-font-weight-bold">{{ action.text }}</span> - </gl-dropdown-item> - <gl-dropdown-divider v-if="index != actions.length - 1" :key="action.key + '_divider'" /> - </template> - </gl-dropdown> - <gl-button - v-else-if="selectedAction" - v-gl-tooltip="selectedAction.tooltip" - v-bind="selectedAction.attrs" - :variant="variant" - :category="category" - :href="selectedAction.href" - @click="handleClick(selectedAction, $event)" - > - {{ selectedAction.text }} - </gl-button> + <span> + <gl-dropdown + v-if="hasMultipleActions" + :id="id" + :text="selectedAction.text" + :split-href="selectedAction.href" + :variant="variant" + :category="category" + split + data-qa-selector="action_dropdown" + @click="handleClick(selectedAction, $event)" + > + <template #button-content> + <span class="gl-dropdown-button-text" v-bind="selectedAction.attrs"> + {{ selectedAction.text }} + </span> + </template> + <template v-for="(action, index) in actions"> + <gl-dropdown-item + :key="action.key" + is-check-item + :is-checked="action.key === selectedAction.key" + :secondary-text="action.secondaryText" + :data-qa-selector="`${action.key}_menu_item`" + :data-testid="`action_${action.key}`" + @click="handleItemClick(action)" + > + <span class="gl-font-weight-bold">{{ action.text }}</span> + </gl-dropdown-item> + <gl-dropdown-divider v-if="index != actions.length - 1" :key="action.key + '_divider'" /> + </template> + </gl-dropdown> + <gl-button + v-else-if="selectedAction" + :id="id" + v-bind="selectedAction.attrs" + :variant="variant" + :category="category" + :href="selectedAction.href" + @click="handleClick(selectedAction, $event)" + > + {{ selectedAction.text }} + </gl-button> + <gl-tooltip v-if="selectedAction.tooltip && showActionTooltip" :target="id"> + {{ selectedAction.tooltip }} + </gl-tooltip> + </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index f5d8811e83c..cb38b3e13bb 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -1,6 +1,7 @@ <script> -import { GlIcon, GlButton, GlTooltipDirective, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui'; import { groupBy } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import EmojiPicker from '~/emoji/components/picker.vue'; import { __, sprintf } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -17,7 +18,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [glFeatureFlagsMixin()], props: { @@ -158,10 +159,7 @@ export default { return; } - // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string - const parsedName = /^[0-9]+$/.test(awardName) ? Number(awardName) : awardName; - - this.$emit('award', parsedName); + this.$emit('award', awardName); if (document.activeElement) document.activeElement.blur(); }, diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue index ed0eb9cc0b8..49181bb847d 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue @@ -1,5 +1,5 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { handleBlobRichViewer } from '~/blob/viewer'; import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue'; import ViewerMixin from './mixins'; diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue index 0117c06c3d5..c7a76af7f74 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue @@ -1,5 +1,6 @@ <script> -import { GlIcon, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlIcon } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { HIGHLIGHT_CLASS_NAME } from './constants'; import ViewerMixin from './mixins'; @@ -9,7 +10,7 @@ export default { GlIcon, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [ViewerMixin], inject: ['blobHash'], diff --git a/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue b/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue index 65b08b608e8..352d03befc3 100644 --- a/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue +++ b/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue @@ -1,5 +1,5 @@ <script> -import { GlSafeHtmlDirective } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import languageLoader from '~/content_editor/services/highlight_js_language_loader'; import CodeBlock from './code_block.vue'; @@ -7,7 +7,7 @@ import CodeBlock from './code_block.vue'; export default { name: 'CodeBlockHighlighted', directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, components: { CodeBlock, diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue index 7a982bc035a..d0a634d8e54 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue @@ -1,12 +1,6 @@ <script> -import { - GlAlert, - GlModal, - GlFormGroup, - GlFormInput, - GlSafeHtmlDirective as SafeHtml, - GlSprintf, -} from '@gitlab/ui'; +import { GlAlert, GlModal, GlFormGroup, GlFormInput, GlSprintf } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { CONFIRM_DANGER_MODAL_BUTTON, CONFIRM_DANGER_MODAL_TITLE, diff --git a/app/assets/javascripts/vue_shared/components/confirm_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_modal.vue index 72504e5bc50..664c3578785 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_modal.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_modal.vue @@ -1,6 +1,7 @@ <script> -import { GlModal, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlModal } from '@gitlab/ui'; import { uniqueId } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import csrf from '~/lib/utils/csrf'; import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from './confirm_modal_eventhub'; import DomElementListener from './dom_element_listener.vue'; 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 3ecfac10f9c..00d12654ee3 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 @@ -1,10 +1,10 @@ <script> -import { GlSkeletonLoader, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import $ from 'jquery'; -import '~/behaviors/markdown/render_gfm'; +import { GlSkeletonLoader } from '@gitlab/ui'; import { forEach, escape } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; const { CancelToken } = axios; let axiosSource; @@ -96,7 +96,7 @@ export default { this.isLoading = false; this.$nextTick(() => { - $(this.$refs.markdownPreview).renderGFM(); + renderGFM(this.$refs.markdownPreview); }); }) .catch(() => { 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 181c1b89e31..d8a2789a419 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 @@ -265,7 +265,6 @@ export default { <gl-dropdown-item v-for="(option, index) in options" :key="index" - data-qa-selector="quick_range_item" :active="isOptionActive(option)" active-class="active" @click="setQuickRange(option)" diff --git a/app/assets/javascripts/vue_shared/components/dismissible_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_alert.vue index 0621ec14c6c..8395bc89790 100644 --- a/app/assets/javascripts/vue_shared/components/dismissible_alert.vue +++ b/app/assets/javascripts/vue_shared/components/dismissible_alert.vue @@ -1,5 +1,6 @@ <script> -import { GlAlert, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlAlert } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; export default { name: 'DismissibleAlert', diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js index 755ce004aa9..993b4c11c0e 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js @@ -8,52 +8,44 @@ export const FILTER_ANY = 'Any'; export const FILTER_CURRENT = 'Current'; export const FILTER_UPCOMING = 'Upcoming'; export const FILTER_STARTED = 'Started'; -export const FILTER_NONE_ANY = [FILTER_NONE, FILTER_ANY]; + +export const FILTERS_NONE_ANY = [FILTER_NONE, FILTER_ANY]; export const OPERATOR_IS = '='; export const OPERATOR_IS_TEXT = __('is'); -export const OPERATOR_IS_NOT = '!='; -export const OPERATOR_IS_NOT_TEXT = __('is not one of'); +export const OPERATOR_NOT = '!='; +export const OPERATOR_NOT_TEXT = __('is not one of'); export const OPERATOR_OR = '||'; export const OPERATOR_OR_TEXT = __('is one of'); -export const OPERATOR_IS_ONLY = [{ value: OPERATOR_IS, description: OPERATOR_IS_TEXT }]; -export const OPERATOR_IS_NOT_ONLY = [{ value: OPERATOR_IS_NOT, description: OPERATOR_IS_NOT_TEXT }]; -export const OPERATOR_OR_ONLY = [{ value: OPERATOR_OR, description: OPERATOR_OR_TEXT }]; -export const OPERATOR_IS_AND_IS_NOT = [...OPERATOR_IS_ONLY, ...OPERATOR_IS_NOT_ONLY]; -export const OPERATOR_IS_NOT_OR = [ - ...OPERATOR_IS_ONLY, - ...OPERATOR_IS_NOT_ONLY, - ...OPERATOR_OR_ONLY, -]; - -export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __('None'), title: __('None') }; -export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __('Any'), title: __('Any') }; -export const DEFAULT_NONE_ANY = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; +export const OPERATORS_IS = [{ value: OPERATOR_IS, description: OPERATOR_IS_TEXT }]; +export const OPERATORS_NOT = [{ value: OPERATOR_NOT, description: OPERATOR_NOT_TEXT }]; +export const OPERATORS_OR = [{ value: OPERATOR_OR, description: OPERATOR_OR_TEXT }]; +export const OPERATORS_IS_NOT = [...OPERATORS_IS, ...OPERATORS_NOT]; +export const OPERATORS_IS_NOT_OR = [...OPERATORS_IS, ...OPERATORS_NOT, ...OPERATORS_OR]; -export const DEFAULT_MILESTONE_UPCOMING = { +export const OPTION_NONE = { value: FILTER_NONE, text: __('None'), title: __('None') }; +export const OPTION_ANY = { value: FILTER_ANY, text: __('Any'), title: __('Any') }; +export const OPTION_CURRENT = { value: FILTER_CURRENT, text: __('Current') }; +export const OPTION_STARTED = { value: FILTER_STARTED, text: __('Started'), title: __('Started') }; +export const OPTION_UPCOMING = { value: FILTER_UPCOMING, text: __('Upcoming'), title: __('Upcoming'), }; -export const DEFAULT_MILESTONE_STARTED = { - value: FILTER_STARTED, - text: __('Started'), - title: __('Started'), -}; -export const DEFAULT_MILESTONES = DEFAULT_NONE_ANY.concat([ - DEFAULT_MILESTONE_UPCOMING, - DEFAULT_MILESTONE_STARTED, -]); -export const SortDirection = { +export const OPTIONS_NONE_ANY = [OPTION_NONE, OPTION_ANY]; + +export const DEFAULT_MILESTONES = OPTIONS_NONE_ANY.concat([OPTION_UPCOMING, OPTION_STARTED]); + +export const SORT_DIRECTION = { descending: 'descending', ascending: 'ascending', }; -export const FILTERED_SEARCH_LABELS = 'labels'; export const FILTERED_SEARCH_TERM = 'filtered-search-term'; +export const TOKEN_TITLE_APPROVED_BY = __('Approved-By'); export const TOKEN_TITLE_ASSIGNEE = s__('SearchToken|Assignee'); export const TOKEN_TITLE_AUTHOR = __('Author'); export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential'); @@ -63,11 +55,14 @@ export const TOKEN_TITLE_MILESTONE = __('Milestone'); export const TOKEN_TITLE_MY_REACTION = __('My-Reaction'); export const TOKEN_TITLE_ORGANIZATION = s__('Crm|Organization'); export const TOKEN_TITLE_RELEASE = __('Release'); +export const TOKEN_TITLE_REVIEWER = s__('SearchToken|Reviewer'); export const TOKEN_TITLE_SOURCE_BRANCH = __('Source Branch'); export const TOKEN_TITLE_STATUS = __('Status'); export const TOKEN_TITLE_TARGET_BRANCH = __('Target Branch'); export const TOKEN_TITLE_TYPE = __('Type'); +export const TOKEN_TITLE_SEARCH_WITHIN = __('Search Within'); +export const TOKEN_TYPE_APPROVED_BY = 'approved-by'; export const TOKEN_TYPE_ASSIGNEE = 'assignee'; export const TOKEN_TYPE_AUTHOR = 'author'; export const TOKEN_TYPE_CONFIDENTIAL = 'confidential'; @@ -84,5 +79,11 @@ export const TOKEN_TYPE_MILESTONE = 'milestone'; export const TOKEN_TYPE_MY_REACTION = 'my-reaction'; export const TOKEN_TYPE_ORGANIZATION = 'organization'; export const TOKEN_TYPE_RELEASE = 'release'; +export const TOKEN_TYPE_REVIEWER = 'reviewer'; +export const TOKEN_TYPE_SOURCE_BRANCH = 'source-branch'; +export const TOKEN_TYPE_STATUS = 'status'; +export const TOKEN_TYPE_TARGET_BRANCH = 'target-branch'; export const TOKEN_TYPE_TYPE = 'type'; export const TOKEN_TYPE_WEIGHT = 'weight'; + +export const TOKEN_TYPE_SEARCH_WITHIN = 'in'; 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 0d0787e7033..34f64dddc41 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 @@ -15,7 +15,7 @@ import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store' import { createAlert } from '~/flash'; import { __ } from '~/locale'; -import { SortDirection } from './constants'; +import { SORT_DIRECTION } from './constants'; import { filterEmptySearchTerm, stripQuotes, uniqueTokens } from './filtered_search_utils'; export default { @@ -107,7 +107,7 @@ export default { recentSearches: [], filterValue: this.initialFilterValue, selectedSortOption: this.sortOptions[0], - selectedSortDirection: SortDirection.descending, + selectedSortDirection: SORT_DIRECTION.descending, }; }, computed: { @@ -130,12 +130,12 @@ export default { ); }, sortDirectionIcon() { - return this.selectedSortDirection === SortDirection.ascending + return this.selectedSortDirection === SORT_DIRECTION.ascending ? 'sort-lowest' : 'sort-highest'; }, sortDirectionTooltip() { - return this.selectedSortDirection === SortDirection.ascending + return this.selectedSortDirection === SORT_DIRECTION.ascending ? __('Sort direction: Ascending') : __('Sort direction: Descending'); }, @@ -267,9 +267,9 @@ export default { }, handleSortDirectionClick() { this.selectedSortDirection = - this.selectedSortDirection === SortDirection.ascending - ? SortDirection.descending - : SortDirection.ascending; + this.selectedSortDirection === SORT_DIRECTION.ascending + ? SORT_DIRECTION.descending + : SORT_DIRECTION.ascending; this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]); }, handleHistoryItemSelected(filters) { diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue index 6a4ff07c999..b0fa3e4c27e 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue @@ -9,7 +9,7 @@ import { } from '@gitlab/ui'; import { debounce } from 'lodash'; -import { DEBOUNCE_DELAY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants'; +import { DEBOUNCE_DELAY, FILTERS_NONE_ANY, OPERATOR_NOT } from '../constants'; import { getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed, @@ -100,9 +100,9 @@ export default { return this.getActiveTokenValue(this.suggestions, this.value.data); }, availableDefaultSuggestions() { - if (this.value.operator === OPERATOR_IS_NOT) { + if (this.value.operator === OPERATOR_NOT) { return this.defaultSuggestions.filter( - (suggestion) => !FILTER_NONE_ANY.includes(suggestion.value), + (suggestion) => !FILTERS_NONE_ANY.includes(suggestion.value), ); } return this.defaultSuggestions; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue index d34cfb922a9..e0fa06c159e 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue @@ -8,7 +8,7 @@ import { isPositiveInteger } from '~/lib/utils/number_utils'; import { __ } from '~/locale'; import searchCrmContactsQuery from '../queries/search_crm_contacts.query.graphql'; -import { DEFAULT_NONE_ANY } from '../constants'; +import { OPTIONS_NONE_ANY } from '../constants'; import BaseToken from './base_token.vue'; @@ -39,7 +39,7 @@ export default { }, computed: { defaultContacts() { - return this.config.defaultContacts || DEFAULT_NONE_ANY; + return this.config.defaultContacts || OPTIONS_NONE_ANY; }, namespace() { return this.config.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue index c7c9350ee93..3f030c8698c 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue @@ -8,7 +8,7 @@ import { isPositiveInteger } from '~/lib/utils/number_utils'; import { __ } from '~/locale'; import searchCrmOrganizationsQuery from '../queries/search_crm_organizations.query.graphql'; -import { DEFAULT_NONE_ANY } from '../constants'; +import { OPTIONS_NONE_ANY } from '../constants'; import BaseToken from './base_token.vue'; @@ -39,7 +39,7 @@ export default { }, computed: { defaultOrganizations() { - return this.config.defaultOrganizations || DEFAULT_NONE_ANY; + return this.config.defaultOrganizations || OPTIONS_NONE_ANY; }, namespace() { return this.config.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue index 929823f7308..74905dc2ae0 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue @@ -3,7 +3,7 @@ import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; -import { DEFAULT_NONE_ANY } from '../constants'; +import { OPTIONS_NONE_ANY } from '../constants'; import { stripQuotes } from '../filtered_search_utils'; export default { @@ -33,7 +33,7 @@ export default { }, computed: { defaultEmojis() { - return this.config.defaultEmojis || DEFAULT_NONE_ANY; + return this.config.defaultEmojis || OPTIONS_NONE_ANY; }, }, methods: { diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue index bce0c11aafd..71c50ef292a 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue @@ -5,7 +5,7 @@ import { createAlert } from '~/flash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; -import { DEFAULT_NONE_ANY } from '../constants'; +import { OPTIONS_NONE_ANY } from '../constants'; import { stripQuotes } from '../filtered_search_utils'; import BaseToken from './base_token.vue'; @@ -38,7 +38,7 @@ export default { }, computed: { defaultLabels() { - return this.config.defaultLabels || DEFAULT_NONE_ANY; + return this.config.defaultLabels || OPTIONS_NONE_ANY; }, }, methods: { diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue index 59701b4959e..6d681aab3ca 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue @@ -3,7 +3,7 @@ import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; -import { DEFAULT_NONE_ANY } from '../constants'; +import { OPTIONS_NONE_ANY } from '../constants'; export default { components: { @@ -32,7 +32,7 @@ export default { }, computed: { defaultReleases() { - return this.config.defaultReleases || DEFAULT_NONE_ANY; + return this.config.defaultReleases || OPTIONS_NONE_ANY; }, }, methods: { diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue index 7c184a3c391..28e65c1185f 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue @@ -4,7 +4,7 @@ import { compact } from 'lodash'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; -import { DEFAULT_NONE_ANY } from '../constants'; +import { OPTIONS_NONE_ANY } from '../constants'; import BaseToken from './base_token.vue'; @@ -30,30 +30,30 @@ export default { }, data() { return { - authors: this.config.initialAuthors || [], + users: this.config.initialUsers || [], loading: false, }; }, computed: { - defaultAuthors() { - return this.config.defaultAuthors || DEFAULT_NONE_ANY; + defaultUsers() { + return this.config.defaultUsers || OPTIONS_NONE_ANY; }, - preloadedAuthors() { - return this.config.preloadedAuthors || []; + preloadedUsers() { + return this.config.preloadedUsers || []; }, }, methods: { - getActiveAuthor(authors, data) { - return authors.find((author) => author.username.toLowerCase() === data.toLowerCase()); + getActiveUser(users, data) { + return users.find((user) => user.username.toLowerCase() === data.toLowerCase()); }, - getAvatarUrl(author) { - return author.avatarUrl || author.avatar_url; + getAvatarUrl(user) { + return user.avatarUrl || user.avatar_url; }, - fetchAuthors(searchTerm) { + fetchUsers(searchTerm) { this.loading = true; const fetchPromise = this.config.fetchPath - ? this.config.fetchAuthors(this.config.fetchPath, searchTerm) - : this.config.fetchAuthors(searchTerm); + ? this.config.fetchUsers(this.config.fetchPath, searchTerm) + : this.config.fetchUsers(searchTerm); fetchPromise .then((res) => { @@ -62,7 +62,7 @@ export default { // return response differently // TODO: rm when completed https://gitlab.com/gitlab-org/gitlab/-/issues/345756 - this.authors = Array.isArray(res) ? compact(res) : compact(res.data); + this.users = Array.isArray(res) ? compact(res) : compact(res.data); }) .catch(() => createAlert({ @@ -83,12 +83,12 @@ export default { :value="value" :active="active" :suggestions-loading="loading" - :suggestions="authors" - :get-active-token-value="getActiveAuthor" - :default-suggestions="defaultAuthors" - :preloaded-suggestions="preloadedAuthors" + :suggestions="users" + :get-active-token-value="getActiveUser" + :default-suggestions="defaultUsers" + :preloaded-suggestions="preloadedUsers" v-bind="$attrs" - @fetch-suggestions="fetchAuthors" + @fetch-suggestions="fetchUsers" v-on="$listeners" > <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> @@ -102,15 +102,15 @@ export default { </template> <template #suggestions-list="{ suggestions }"> <gl-filtered-search-suggestion - v-for="author in suggestions" - :key="author.username" - :value="author.username" + v-for="user in suggestions" + :key="user.username" + :value="user.username" > <div class="gl-display-flex"> - <gl-avatar :size="32" :src="getAvatarUrl(author)" /> + <gl-avatar :size="32" :src="getAvatarUrl(user)" /> <div> - <div>{{ author.name }}</div> - <div>@{{ author.username }}</div> + <div>{{ user.name }}</div> + <div>@{{ user.username }}</div> </div> </div> </gl-filtered-search-suggestion> diff --git a/app/assets/javascripts/vue_shared/components/group_select/group_select.vue b/app/assets/javascripts/vue_shared/components/group_select/group_select.vue index 1de6c0121bc..5db723e1e5a 100644 --- a/app/assets/javascripts/vue_shared/components/group_select/group_select.vue +++ b/app/assets/javascripts/vue_shared/components/group_select/group_select.vue @@ -1,6 +1,6 @@ <script> import { debounce } from 'lodash'; -import { GlListbox } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; import Api from '~/api'; import { __ } from '~/locale'; @@ -18,7 +18,7 @@ const MINIMUM_QUERY_LENGTH = 3; export default { components: { - GlListbox, + GlCollapsibleListbox, }, props: { inputName: { @@ -167,7 +167,7 @@ export default { <template> <div> - <gl-listbox + <gl-collapsible-listbox ref="listbox" v-model="selected" :header-text="$options.i18n.selectGroup" @@ -188,7 +188,7 @@ export default { </div> <div class="gl-text-gray-300">{{ item.full_path }}</div> </template> - </gl-listbox> + </gl-collapsible-listbox> <div class="flash-container"></div> <input :id="inputId" data-testid="input" type="hidden" :name="inputName" :value="inputValue" /> </div> diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index 96f7427dda1..3c4ae08d2f7 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -1,12 +1,6 @@ <script> -import { - GlTooltipDirective, - GlButton, - GlSafeHtmlDirective, - GlAvatarLink, - GlAvatarLabeled, - GlTooltip, -} from '@gitlab/ui'; +import { GlTooltipDirective, GlButton, GlAvatarLink, GlAvatarLabeled, GlTooltip } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { glEmojiTag } from '~/emoji'; import { __, sprintf } from '~/locale'; @@ -31,7 +25,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, EMOJI_REF: 'EMOJI_REF', props: { diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue index f349aa78bac..92d468cf970 100644 --- a/app/assets/javascripts/vue_shared/components/help_popover.vue +++ b/app/assets/javascripts/vue_shared/components/help_popover.vue @@ -1,5 +1,6 @@ <script> -import { GlButton, GlPopover, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlButton, GlPopover } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; /** * Render a button with a question mark icon @@ -12,7 +13,7 @@ export default { GlPopover, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { options: { diff --git a/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.stories.js b/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.stories.js new file mode 100644 index 00000000000..4106de371cb --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.stories.js @@ -0,0 +1,26 @@ +import ListboxInput from './listbox_input.vue'; + +export default { + component: ListboxInput, + title: 'vue_shared/listbox_input', +}; + +const Template = (args, { argTypes }) => ({ + components: { ListboxInput }, + data() { + return { selected: null }; + }, + props: Object.keys(argTypes), + template: '<listbox-input v-model="selected" v-bind="$props" />', +}); + +export const Default = Template.bind({}); +Default.args = { + name: 'input_name', + defaultToggleText: 'Select an option', + items: [ + { text: 'Option 1', value: '1' }, + { text: 'Option 2', value: '2' }, + { text: 'Option 3', value: '3' }, + ], +}; diff --git a/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue b/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue new file mode 100644 index 00000000000..b1809e6a9f3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue @@ -0,0 +1,110 @@ +<script> +import { GlListbox } from '@gitlab/ui'; +import { __ } from '~/locale'; + +const MIN_ITEMS_COUNT_FOR_SEARCHING = 20; + +export default { + i18n: { + noResultsText: __('No results found'), + }, + components: { + GlListbox, + }, + model: GlListbox.model, + props: { + name: { + type: String, + required: true, + }, + defaultToggleText: { + type: String, + required: true, + }, + selected: { + type: String, + required: false, + default: null, + }, + items: { + type: GlListbox.props.items.type, + required: true, + }, + }, + data() { + return { + searchString: '', + }; + }, + computed: { + allOptions() { + const allOptions = []; + + const getOptions = (options) => { + for (let i = 0; i < options.length; i += 1) { + const option = options[i]; + if (option.options) { + getOptions(option.options); + } else { + allOptions.push(option); + } + } + }; + getOptions(this.items); + + return allOptions; + }, + isGrouped() { + return this.items.some((item) => item.options !== undefined); + }, + isSearchable() { + return this.allOptions.length > MIN_ITEMS_COUNT_FOR_SEARCHING; + }, + filteredItems() { + const searchString = this.searchString.toLowerCase(); + + if (!searchString) { + return this.items; + } + + if (this.isGrouped) { + return this.items + .map(({ text, options }) => { + return { + text, + options: options.filter((option) => option.text.toLowerCase().includes(searchString)), + }; + }) + .filter(({ options }) => options.length); + } + + return this.items.filter((item) => item.text.toLowerCase().includes(searchString)); + }, + toggleText() { + return this.selected + ? this.allOptions.find((option) => option.value === this.selected).text + : this.defaultToggleText; + }, + }, + methods: { + search(searchString) { + this.searchString = searchString; + }, + }, +}; +</script> + +<template> + <div> + <gl-listbox + :selected="selected" + :toggle-text="toggleText" + :items="filteredItems" + :searchable="isSearchable" + :no-results-text="$options.i18n.noResultsText" + @search="search" + @select="$emit($options.model.event, $event)" + /> + <input ref="input" type="hidden" :name="name" :value="selected" /> + </div> +</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 index caec49c557a..f51ec715678 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue @@ -74,7 +74,7 @@ export default { @submit="onApply" /> <gl-button - class="gl-w-auto! gl-mt-3 gl-text-center! gl-hover-text-white! gl-transition-medium! float-right" + class="gl-w-auto! gl-mt-3 gl-text-center! gl-transition-medium! float-right" category="primary" variant="confirm" data-qa-selector="commit_with_custom_message_button" diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 657e4498b53..b5f2602af5e 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -1,15 +1,16 @@ <script> -import { GlIcon, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlIcon } from '@gitlab/ui'; import $ from 'jquery'; -import '~/behaviors/markdown/render_gfm'; import { debounce, unescape } from 'lodash'; import { createAlert } from '~/flash'; import GLForm from '~/gl_form'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import axios from '~/lib/utils/axios_utils'; import { stripHtml } from '~/lib/utils/text_utility'; import { __, sprintf } from '~/locale'; import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import MarkdownHeader from './header.vue'; import MarkdownToolbar from './toolbar.vue'; @@ -25,7 +26,7 @@ export default { Suggestions, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [glFeatureFlagsMixin()], props: { @@ -313,7 +314,9 @@ export default { this.markdownPreview = data.body || __('Nothing to preview.'); this.$nextTick() - .then(() => $(this.$refs['markdown-preview']).renderGFM()) + .then(() => { + renderGFM(this.$refs['markdown-preview']); + }) .catch(() => createAlert({ message: __('Error rendering Markdown preview'), diff --git a/app/assets/javascripts/vue_shared/components/markdown/field_view.vue b/app/assets/javascripts/vue_shared/components/markdown/field_view.vue index d77123371f2..84d40db07bb 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field_view.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field_view.vue @@ -1,15 +1,9 @@ <script> -import $ from 'jquery'; -import '~/behaviors/markdown/render_gfm'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; export default { mounted() { - this.renderGFM(); - }, - methods: { - renderGFM() { - $(this.$el).renderGFM(); - }, + renderGFM(this.$el); }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue index c0712e46613..d01eae0308f 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -82,6 +82,11 @@ export default { required: false, default: false, }, + useBottomToolbar: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -197,6 +202,7 @@ export default { :uploads-path="uploadsPath" :markdown="value" :autofocus="contentEditorAutofocused" + :use-bottom-toolbar="useBottomToolbar" @initialized="setEditorAsAutofocused" @change="updateMarkdownFromContentEditor" @loading="disableSwitchEditingControl" diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue index a04f8616acb..0b598d3acaf 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue @@ -1,5 +1,5 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; export default { name: 'SuggestionDiffRow', diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 30d72332c90..c307601e670 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -1,6 +1,6 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import Vue from 'vue'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; import SuggestionDiff from './suggestion_diff.vue'; diff --git a/app/assets/javascripts/vue_shared/components/markdown_drawer/makrdown_drawer.stories.js b/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.stories.js index 03bd64e2a57..03bd64e2a57 100644 --- a/app/assets/javascripts/vue_shared/components/markdown_drawer/makrdown_drawer.stories.js +++ b/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.stories.js diff --git a/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue b/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue index a4b509f8656..379f22fdc6f 100644 --- a/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue +++ b/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue @@ -1,9 +1,9 @@ <script> -import { GlSafeHtmlDirective as SafeHtml, GlDrawer, GlAlert, GlSkeletonLoader } from '@gitlab/ui'; -import $ from 'jquery'; -import '~/behaviors/markdown/render_gfm'; +import { GlDrawer, GlAlert, GlSkeletonLoader } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__ } from '~/locale'; import { contentTop } from '~/lib/utils/common_utils'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { getRenderedMarkdown } from './utils/fetch'; export const cache = {}; @@ -34,13 +34,9 @@ export default { title: '', body: null, open: false, + drawerTop: '0px', }; }, - computed: { - drawerOffsetTop() { - return `${contentTop()}px`; - }, - }, watch: { documentPath: { immediate: true, @@ -76,18 +72,23 @@ export default { cache[this.documentPath] = { title, body }; } }, + getDrawerTop() { + this.drawerTop = `${contentTop()}px`; + }, renderGLFM() { this.$nextTick(() => { - $(this.$refs['content-element']).renderGFM(); + renderGFM(this.$refs['content-element']); }); }, closeDrawer() { this.open = false; }, toggleDrawer() { + this.getDrawerTop(); this.open = !this.open; }, openDrawer() { + this.getDrawerTop(); this.open = true; }, }, @@ -97,7 +98,7 @@ export default { }; </script> <template> - <gl-drawer :header-height="drawerOffsetTop" :open="open" header-sticky @close="closeDrawer"> + <gl-drawer :header-height="drawerTop" :open="open" header-sticky @close="closeDrawer"> <template #title> <h4 data-testid="title-element" class="gl-m-0">{{ title }}</h4> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js b/app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js index 7c8e1bc160a..27237f2f16b 100644 --- a/app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js +++ b/app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js @@ -16,7 +16,7 @@ export const getRenderedMarkdown = (documentPath) => { return axios .get(helpPagePath(documentPath)) .then(({ data }) => { - const { body, title } = splitDocument(data.html); + const { body, title } = splitDocument(data); return { body, title, diff --git a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue index e23721da223..2cadc87eca3 100644 --- a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue +++ b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue @@ -1,5 +1,5 @@ <script> -import { GlFormGroup, GlFormInput, GlLoadingIcon, GlModal, GlTab } from '@gitlab/ui'; +import { GlFormGroup, GlFormInput, GlLoadingIcon, GlModal } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import { __, s__ } from '~/locale'; import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; @@ -11,7 +11,6 @@ export default { GlFormInput, GlLoadingIcon, GlModal, - GlTab, MetricImagesTable, UploadDropzone, }, @@ -82,7 +81,7 @@ export default { </script> <template> - <gl-tab :title="s__('Incident|Metrics')" data-testid="metrics-tab"> + <div> <div v-if="isLoadingMetricImages"> <gl-loading-icon class="gl-p-5" size="sm" /> </div> @@ -117,5 +116,5 @@ export default { :drop-description-message="$options.i18n.dropDescription" @change="openMetricDialog" /> - </gl-tab> + </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue index cf34a60c363..748d6082abd 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -16,8 +16,9 @@ * :note="{body: 'This is a note'}" * /> */ -import { GlSafeHtmlDirective as SafeHtml, GlAvatarLink, GlAvatar } from '@gitlab/ui'; +import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; import { mapGetters } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { renderMarkdown } from '~/notes/utils'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index 1ae5045b34f..1cbbdf0deb0 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -16,22 +16,17 @@ * }" * /> */ -import { - GlButton, - GlSkeletonLoader, - GlTooltipDirective, - GlIcon, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; +import { GlButton, GlSkeletonLoader, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import $ from 'jquery'; import { mapGetters, mapActions, mapState } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history'; -import '~/behaviors/markdown/render_gfm'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import NoteHeader from '~/notes/components/note_header.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { spriteIcon } from '~/lib/utils/common_utils'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import TimelineEntryItem from './timeline_entry_item.vue'; const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; @@ -94,7 +89,7 @@ export default { }, }, mounted() { - $(this.$refs['gfm-content']).renderGFM(); + renderGFM(this.$refs['gfm-content']); }, methods: { ...mapActions(['fetchDescriptionVersion', 'softDeleteDescriptionVersion']), @@ -205,7 +200,7 @@ export default { <tr v-for="line in lines" v-once :key="line.line_code" class="line_holder"> <td :class="line.type" - class="diff-line-num old_line gl-border-bottom-0! gl-border-top-0!" + class="diff-line-num old_line gl-border-bottom-0! gl-border-top-0! gl-border-0! gl-rounded-0!" > {{ line.old_line }} </td> @@ -217,7 +212,7 @@ export default { </td> <td :class="line.type" - class="line_content gl-display-table-cell!" + class="line_content gl-display-table-cell! gl-border-0! gl-rounded-0!" v-html="line.rich_text /* eslint-disable-line vue/no-v-html */" ></td> </tr> diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue index 867222279b2..57e3a97244e 100644 --- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue +++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue @@ -1,22 +1,19 @@ <script> -import { - GlAlert, - GlBadge, - GlPagination, - GlTab, - GlTabs, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; +import { GlAlert, GlBadge, GlPagination, GlTab, GlTabs } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import Api from '~/api'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import Tracking from '~/tracking'; import { - OPERATOR_IS_ONLY, + FILTERED_SEARCH_TERM, + OPERATORS_IS, TOKEN_TITLE_ASSIGNEE, TOKEN_TITLE_AUTHOR, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import { initialPaginationState, defaultI18n, defaultPageSize } from './constants'; import { isAny } from './utils'; @@ -95,7 +92,7 @@ export default { filterSearchTokens: { type: Array, required: false, - default: () => ['author_username', 'assignee_username'], + default: () => [TOKEN_TYPE_AUTHOR, TOKEN_TYPE_ASSIGNEE], }, }, data() { @@ -113,26 +110,26 @@ export default { defaultTokens() { return [ { - type: 'author_username', + type: TOKEN_TYPE_AUTHOR, icon: 'user', title: TOKEN_TITLE_AUTHOR, unique: true, symbol: '@', - token: AuthorToken, - operators: OPERATOR_IS_ONLY, + token: UserToken, + operators: OPERATORS_IS, fetchPath: this.projectPath, - fetchAuthors: Api.projectUsers.bind(Api), + fetchUsers: Api.projectUsers.bind(Api), }, { - type: 'assignee_username', + type: TOKEN_TYPE_ASSIGNEE, icon: 'user', title: TOKEN_TITLE_ASSIGNEE, unique: true, symbol: '@', - token: AuthorToken, - operators: OPERATOR_IS_ONLY, + token: UserToken, + operators: OPERATORS_IS, fetchPath: this.projectPath, - fetchAuthors: Api.projectUsers.bind(Api), + fetchUsers: Api.projectUsers.bind(Api), }, ]; }, @@ -144,14 +141,14 @@ export default { if (this.authorUsername) { value.push({ - type: 'author_username', + type: TOKEN_TYPE_AUTHOR, value: { data: this.authorUsername }, }); } if (this.assigneeUsername) { value.push({ - type: 'assignee_username', + type: TOKEN_TYPE_ASSIGNEE, value: { data: this.assigneeUsername }, }); } @@ -226,13 +223,13 @@ export default { filters.forEach((filter) => { if (typeof filter === 'object') { switch (filter.type) { - case 'author_username': + case TOKEN_TYPE_AUTHOR: filterParams.authorUsername = isAny(filter.value.data); break; - case 'assignee_username': + case TOKEN_TYPE_ASSIGNEE: filterParams.assigneeUsername = isAny(filter.value.data); break; - case 'filtered-search-term': + case FILTERED_SEARCH_TERM: if (filter.value.data !== '') filterParams.search = filter.value.data; break; default: diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue index 66643ff4026..16bc8070dc1 100644 --- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue @@ -1,9 +1,10 @@ <script> -import { GlButton, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlButton, GlIcon } from '@gitlab/ui'; import { isString } from 'lodash'; import highlight from '~/lib/utils/highlight'; import { truncateNamespace } from '~/lib/utils/text_utility'; import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; +import SafeHtml from '~/vue_shared/directives/safe_html'; export default { name: 'ProjectListItem', diff --git a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue index 8c9c7c63db1..c990baaa2f3 100644 --- a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue +++ b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue @@ -1,7 +1,7 @@ <script> import { GlSorting, GlSortingItem, GlFilteredSearch } from '@gitlab/ui'; -import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import { SORT_DIRECTION_UI } from '~/search/sort/constants'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; const ASCENDING_ORDER = 'asc'; const DESCENDING_ORDER = 'desc'; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js deleted file mode 100644 index 465ee9aa0d4..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js +++ /dev/null @@ -1,21 +0,0 @@ -import TodoButton from './todo_button.vue'; - -export default { - component: TodoButton, - title: 'vue_shared/sidebar/todo_toggle/todo_button', -}; - -const Template = (args, { argTypes }) => ({ - components: { TodoButton }, - props: Object.keys(argTypes), - template: '<todo-button v-bind="$props" v-on="$props" />', -}); - -export const Default = Template.bind({}); -Default.argTypes = { - isTodo: { - description: 'True if to-do is unresolved (i.e. not "done")', - control: { type: 'boolean' }, - }, - click: { action: 'clicked' }, -}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue index a2d8b7cbd15..28a16cd846a 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue @@ -1,6 +1,6 @@ <script> -import { GlIntersectionObserver, GlSafeHtmlDirective } from '@gitlab/ui'; -import { scrollToElement } from '~/lib/utils/common_utils'; +import { GlIntersectionObserver } from '@gitlab/ui'; +import LineHighlighter from '~/blob/line_highlighter'; import ChunkLine from './chunk_line.vue'; /* @@ -20,9 +20,6 @@ export default { ChunkLine, GlIntersectionObserver, }, - directives: { - SafeHtml: GlSafeHtmlDirective, - }, props: { isFirstChunk: { type: Boolean, @@ -84,12 +81,14 @@ export default { return; } - window.requestIdleCallback(() => { + window.requestIdleCallback(async () => { this.isLoading = false; const { hash } = this.$route; if (hash && this.totalChunks > 0 && this.totalChunks === this.chunkIndex + 1) { // when the last chunk is loaded scroll to the hash - scrollToElement(hash, { behavior: 'auto' }); + await this.$nextTick(); + const lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); + lineHighlighter.highlightHash(hash); } }); }, diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue index 0bf19f83d86..ce6741f33b1 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue @@ -1,11 +1,11 @@ <script> -import { GlSafeHtmlDirective } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getPageParamValue, getPageSearchString } from '~/blob/utils'; export default { directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [glFeatureFlagMixin()], props: { diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js index fca2616f069..cd15916851c 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js @@ -4,6 +4,7 @@ import godepsJsonLinker from './utils/godeps_json_linker'; import gemfileLinker from './utils/gemfile_linker'; import podspecJsonLinker from './utils/podspec_json_linker'; import composerJsonLinker from './utils/composer_json_linker'; +import goSumLinker from './utils/go_sum_linker'; const DEPENDENCY_LINKERS = { package_json: packageJsonLinker, @@ -12,6 +13,7 @@ const DEPENDENCY_LINKERS = { gemfile: gemfileLinker, podspec_json: podspecJsonLinker, composer_json: composerJsonLinker, + go_sum: goSumLinker, }; /** diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/go_sum_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/go_sum_linker.js new file mode 100644 index 00000000000..b290dfa78b9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/go_sum_linker.js @@ -0,0 +1,34 @@ +import { createLink } from './dependency_linker_util'; + +const openTag = '<span class="">'; +const closeTag = '</span>'; +const TAG_URL = 'https://sum.golang.org/lookup/'; +const GO_PACKAGE_URL = 'https://pkg.go.dev/'; + +const DEPENDENCY_REGEX = new RegExp( + /* + * Detects dependencies inside of content that is highlighted by Highlight.js + * Example: '<span class="">cloud.google.com/Go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=</span>' + * Group 1 (packagePath): 'cloud.google.com/Go/bigquery' + * Group 2 (version): 'v1.0.1/go.mod' + * Group 3 (base64url): 'i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=' + */ + `${openTag}(.*) (v.*) h1:(.*)${closeTag}`, + 'gm', +); + +const handleReplace = (packagePath, version, tag) => { + const lowercasePath = packagePath.toLowerCase(); + const packageHref = `${GO_PACKAGE_URL}${lowercasePath}`; + const packageLink = createLink(packageHref, packagePath); + const tagHref = `${TAG_URL}${lowercasePath}@${version.split('/go.mod')[0]}`; + const tagLink = createLink(tagHref, tag); + + return `${openTag}${packageLink} ${version} h1:${tagLink}${closeTag}`; +}; + +export default (result) => { + return result.value.replace(DEPENDENCY_REGEX, (_, packagePath, version, tag) => + handleReplace(packagePath, version, tag), + ); +}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue index f621a23734a..0cfee93ce5d 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue @@ -1,5 +1,5 @@ <script> -import { GlSafeHtmlDirective, GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon } from '@gitlab/ui'; import LineHighlighter from '~/blob/line_highlighter'; import eventHub from '~/notes/event_hub'; import languageLoader from '~/content_editor/services/highlight_js_language_loader'; @@ -28,9 +28,6 @@ export default { GlLoadingIcon, Chunk, }, - directives: { - SafeHtml: GlSafeHtmlDirective, - }, mixins: [Tracking.mixin()], props: { blob: { diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 80c1fcbacfa..d06bc7b8f98 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -4,11 +4,11 @@ import { GlLink, GlSkeletonLoader, GlIcon, - GlSafeHtmlDirective, GlSprintf, GlButton, GlAvatarLabeled, } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { glEmojiTag } from '~/emoji'; import { createAlert } from '~/flash'; import { followUser, unfollowUser } from '~/rest_api'; @@ -44,7 +44,7 @@ export default { GlAvatarLabeled, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [Tracking.mixin()], props: { diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue index 6d179b3dc92..383dc27ea5e 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -1,14 +1,16 @@ <script> -import { GlModal, GlSprintf, GlLink } from '@gitlab/ui'; +import { GlModal, GlSprintf, GlLink, GlPopover } from '@gitlab/ui'; import { s__, __ } from '~/locale'; +import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; import ActionsButton from '~/vue_shared/components/actions_button.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import ConfirmForkModal from '~/vue_shared/components/confirm_fork_modal.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -const KEY_EDIT = 'edit'; -const KEY_WEB_IDE = 'webide'; -const KEY_GITPOD = 'gitpod'; -const KEY_PIPELINE_EDITOR = 'pipeline_editor'; +export const KEY_EDIT = 'edit'; +export const KEY_WEB_IDE = 'webide'; +export const KEY_GITPOD = 'gitpod'; +export const KEY_PIPELINE_EDITOR = 'pipeline_editor'; export const i18n = { modal: { @@ -25,6 +27,9 @@ export const i18n = { ), }; +export const PREFERRED_EDITOR_KEY = 'gl-web-ide-button-selected'; +export const PREFERRED_EDITOR_RESET_KEY = 'gl-web-ide-button-selected-reset'; + export default { components: { ActionsButton, @@ -32,9 +37,12 @@ export default { GlModal, GlSprintf, GlLink, + GlPopover, ConfirmForkModal, + UserCalloutDismisser, }, i18n, + mixins: [glFeatureFlagsMixin()], props: { isFork: { type: Boolean, @@ -131,6 +139,11 @@ export default { required: false, default: '', }, + webIdePromoPopoverImg: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -296,6 +309,12 @@ export default { }, }; }, + displayVscodeWebIdeCallout() { + return this.glFeatures.vscodeWebIde && !this.showEditButton; + }, + }, + mounted() { + this.resetPreferredEditor(); }, methods: { select(key) { @@ -304,41 +323,109 @@ export default { showModal(dataKey) { this[dataKey] = true; }, + resetPreferredEditor() { + if (!this.glFeatures.vscodeWebIde || this.showEditButton) { + return; + } + + if (localStorage.getItem(PREFERRED_EDITOR_RESET_KEY) === 'true') { + return; + } + + localStorage.setItem(PREFERRED_EDITOR_KEY, KEY_WEB_IDE); + localStorage.setItem(PREFERRED_EDITOR_RESET_KEY, true); + + this.select(KEY_WEB_IDE); + }, + dismissCalloutOnActionClicked(dismiss) { + if (this.displayVscodeWebIdeCallout) { + dismiss(); + } + }, }, + webIdeButtonId: 'web-ide-link', + PREFERRED_EDITOR_KEY, }; </script> <template> - <div class="gl-sm-ml-3"> - <actions-button - :actions="actions" - :selected-key="selection" - :variant="isBlob ? 'confirm' : 'default'" - :category="isBlob ? 'primary' : 'secondary'" - @select="select" - /> - <local-storage-sync - storage-key="gl-web-ide-button-selected" - :value="selection" - as-string - @input="select" - /> - <gl-modal - v-if="computedShowGitpodButton && !gitpodEnabled" - v-model="showEnableGitpodModal" - v-bind="enableGitpodModalProps" - > - <gl-sprintf :message="$options.i18n.modal.content"> - <template #link="{ content }"> - <gl-link :href="userPreferencesGitpodPath">{{ content }}</gl-link> - </template> - </gl-sprintf> - </gl-modal> - <confirm-fork-modal - v-if="showWebIdeButton || showEditButton" - v-model="showForkModal" - :modal-id="forkModalId" - :fork-path="forkPath" - /> - </div> + <user-callout-dismisser + :skip-query="!displayVscodeWebIdeCallout" + feature-name="vscode_web_ide_callout" + > + <template #default="{ dismiss, shouldShowCallout }"> + <div class="gl-sm-ml-3"> + <actions-button + :id="$options.webIdeButtonId" + :actions="actions" + :selected-key="selection" + :variant="isBlob ? 'confirm' : 'default'" + :category="isBlob ? 'primary' : 'secondary'" + :show-action-tooltip="!displayVscodeWebIdeCallout || !shouldShowCallout" + @select="select" + @actionClicked="dismissCalloutOnActionClicked(dismiss)" + /> + <local-storage-sync + :storage-key="$options.PREFERRED_EDITOR_KEY" + :value="selection" + as-string + @input="select" + /> + <gl-modal + v-if="computedShowGitpodButton && !gitpodEnabled" + v-model="showEnableGitpodModal" + v-bind="enableGitpodModalProps" + > + <gl-sprintf :message="$options.i18n.modal.content"> + <template #link="{ content }"> + <gl-link :href="userPreferencesGitpodPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-modal> + <confirm-fork-modal + v-if="showWebIdeButton || showEditButton" + v-model="showForkModal" + :modal-id="forkModalId" + :fork-path="forkPath" + /> + <gl-popover + v-if="displayVscodeWebIdeCallout" + :target="$options.webIdeButtonId" + :show="shouldShowCallout" + :css-classes="['web-ide-promo-popover']" + :boundary-padding="80" + show-close-button + triggers="manual" + @close-button-clicked="dismiss" + > + <img + :src="webIdePromoPopoverImg" + class="web-ide-promo-popover-illustration" + width="280" + height="140" + /> + <div class="gl-mx-2"> + <h5 class="gl-mt-3 gl-mb-3">{{ __('The new Web IDE') }}</h5> + <p> + {{ + __( + 'VS Code in your browser. View code and make changes from the same UI as in your local IDE.', + ) + }} + </p> + <gl-link + class="gl-button btn btn-confirm block gl-mb-4 gl-mt-5" + variant="confirm" + category="primary" + target="_blank" + :href="webIdeUrl" + block + > + {{ __('Try it out now') }} + </gl-link> + </div> + </gl-popover> + </div> + </template> + </user-callout-dismisser> </template> diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js index a851f84ed2f..2f85a29fb84 100644 --- a/app/assets/javascripts/vue_shared/constants.js +++ b/app/assets/javascripts/vue_shared/constants.js @@ -13,7 +13,9 @@ export const SHORT_DATE_FORMAT = 'd mmm, yyyy'; export const ISO_SHORT_FORMAT = 'yyyy-mm-dd'; -export const DATE_FORMATS = [SHORT_DATE_FORMAT, ISO_SHORT_FORMAT]; +export const LONG_DATE_FORMAT_WITH_TZ = 'yyyy-mm-dd HH:MM:ss Z'; + +export const DATE_FORMATS = [SHORT_DATE_FORMAT, ISO_SHORT_FORMAT, LONG_DATE_FORMAT_WITH_TZ]; const getTimeLabel = (days) => n__('1 day', '%d days', days); diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue index 25799171905..2644befc902 100644 --- a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue +++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue @@ -1,8 +1,8 @@ <script> import { GlForm, GlFormInput, GlFormGroup } from '@gitlab/ui'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; -import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; -import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; +import { DropdownVariant } from '~/sidebar/components/labels/labels_select_vue/constants'; +import LabelsSelect from '~/sidebar/components/labels/labels_select_vue/labels_select_root.vue'; export default { LabelSelectVariant: DropdownVariant, diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue new file mode 100644 index 00000000000..b3f9c8d9fcd --- /dev/null +++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue @@ -0,0 +1,92 @@ +<script> +import { GlFormGroup, GlIcon } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import LabelsSelect from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue'; +import { __ } from '~/locale'; + +export default { + components: { + GlFormGroup, + GlIcon, + LabelsSelect, + }, + inject: [ + 'allowLabelRemove', + 'attrWorkspacePath', + 'fieldName', + 'fullPath', + 'labelsFilterBasePath', + 'initialLabels', + 'issuableType', + 'labelType', + 'variant', + 'workspaceType', + ], + data() { + return { + selectedLabels: this.initialLabels || [], + }; + }, + methods: { + handleUpdateSelectedLabels({ labels }) { + this.selectedLabels = labels.map((label) => ({ ...label, id: getIdFromGraphQLId(label.id) })); + }, + handleLabelRemove(labelId) { + this.selectedLabels = this.selectedLabels.filter((label) => label.id !== labelId); + }, + }, + i18n: { + fieldLabel: __('Labels'), + dropdownButtonText: __('Select label'), + listTitle: __('Select label'), + createTitle: __('Create project label'), + manageTitle: __('Manage project labels'), + emptySelection: __('None'), + }, +}; +</script> + +<template> + <gl-form-group class="row" label-class="gl-display-none"> + <label class="col-12 gl-display-flex gl-align-center gl-mb-1"> + {{ $options.i18n.fieldLabel }} + <div class="gl-ml-3"> + <gl-icon name="labels" /> + <span class="gl-font-base gl-line-height-24">{{ selectedLabels.length }}</span> + </div> + </label> + <div class="col-12"> + <div class="issuable-form-select-holder"> + <input + v-for="selectedLabel in selectedLabels" + :key="selectedLabel.id" + :value="selectedLabel.id" + :name="fieldName" + type="hidden" + /> + <labels-select + class="block labels" + :allow-label-remove="allowLabelRemove" + :allow-multiselect="true" + :show-embedded-labels-list="true" + :full-path="fullPath" + :attr-workspace-path="attrWorkspacePath" + :labels-filter-base-path="labelsFilterBasePath" + :dropdown-button-text="$options.i18n.dropdownButtonText" + :labels-list-title="$options.i18n.listTitle" + :footer-create-label-title="$options.i18n.createTitle" + :footer-manage-label-title="$options.i18n.manageTitle" + :variant="variant" + :workspace-type="workspaceType" + :issuable-type="issuableType" + :label-create-type="labelType" + :selected-labels="selectedLabels" + @updateSelectedLabels="handleUpdateSelectedLabels" + @onLabelRemove="handleLabelRemove" + > + {{ $options.i18n.emptySelection }} + </labels-select> + </div> + </div> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue index 30b7b073ac3..5b303b9a314 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue @@ -318,8 +318,8 @@ export default { <slot name="statistics"></slot> <li v-if="showDiscussions" - data-testid="issuable-discussions" - class="issuable-comments gl-display-none gl-sm-display-block" + class="gl-display-none gl-sm-display-block" + data-testid="issuable-comments" > <gl-link v-gl-tooltip.top diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue index dd3d7c8f4d6..5b6c5bf6e03 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue @@ -331,6 +331,7 @@ export default { <slot name="sidebar-items" :checked-issuables="bulkEditIssuables"></slot> </template> </issuable-bulk-edit-sidebar> + <slot name="list-body"></slot> <ul v-if="issuablesLoading" class="content-list"> <li v-for="n in skeletonItemCount" :key="n" class="issue gl-px-5! gl-py-5!"> <gl-skeleton-loader /> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue index d4e9120ff17..ce1851ab873 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue @@ -1,7 +1,6 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import $ from 'jquery'; -import '~/behaviors/markdown/render_gfm'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; export default { directives: { @@ -26,12 +25,7 @@ export default { }, }, mounted() { - this.renderGFM(); - }, - methods: { - renderGFM() { - $(this.$refs.gfmContainer).renderGFM(); - }, + renderGFM(this.$refs.gfmContainer); }, }; </script> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue index 35124bd15d2..fd94245b7c9 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue @@ -1,12 +1,6 @@ <script> -import { - GlIcon, - GlBadge, - GlButton, - GlIntersectionObserver, - GlTooltipDirective, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; +import { GlIcon, GlBadge, GlButton, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __ } from '~/locale'; import { IssuableStates } from '~/vue_shared/issuable/list/constants'; diff --git a/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue b/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue index e42720bf1db..ae40076ca96 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue @@ -1,4 +1,6 @@ <script> +import projectNew from '~/projects/project_new'; + export default { inheritAttrs: false, props: { @@ -16,6 +18,7 @@ export default { this.source = legacyEntry.parentNode; this.$el.appendChild(legacyEntry); legacyEntry.classList.add('active'); + projectNew.bindEvents(); } }, diff --git a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue index 5cd2018bb8c..b6a459f21e0 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue @@ -1,5 +1,5 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import Tracking from '~/tracking'; export default { diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue index 624ae7027d5..318adec2319 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue @@ -1,5 +1,6 @@ <script> -import { GlBreadcrumb, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlBreadcrumb, GlIcon } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import NewTopLevelGroupAlert from '~/groups/components/new_top_level_group_alert.vue'; import LegacyContainer from './components/legacy_container.vue'; 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 0e1975e1c09..b739baad5d7 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 @@ -2,8 +2,8 @@ import { mapActions, mapGetters } from 'vuex'; import { createAlert } from '~/flash'; import { s__ } from '~/locale'; -import ReportSection from '~/reports/components/report_section.vue'; -import { ERROR, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '~/reports/constants'; +import ReportSection from '~/ci/reports/components/report_section.vue'; +import { ERROR, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '~/ci/reports/constants'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import HelpIcon from './components/help_icon.vue'; import SecurityReportDownloadDropdown from './components/security_report_download_dropdown.vue'; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/getters.js b/app/assets/javascripts/vue_shared/security_reports/store/getters.js index 08f6bcca15b..c274f531139 100644 --- a/app/assets/javascripts/vue_shared/security_reports/store/getters.js +++ b/app/assets/javascripts/vue_shared/security_reports/store/getters.js @@ -1,5 +1,5 @@ import { s__, sprintf } from '~/locale'; -import { LOADING, ERROR, SUCCESS } from '~/reports/constants'; +import { LOADING, ERROR, SUCCESS } from '~/ci/reports/constants'; import { TRANSLATION_IS_LOADING } from './messages'; import { countVulnerabilities, groupedTextBuilder } from './utils'; 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 a6628fa0f9f..f3cb5fc16f0 100644 --- a/app/assets/javascripts/vue_shared/security_reports/store/utils.js +++ b/app/assets/javascripts/vue_shared/security_reports/store/utils.js @@ -29,7 +29,13 @@ export const fetchDiffData = (state, endpoint, category) => { */ export const enrichVulnerabilityWithFeedback = (vulnerability, feedback = []) => feedback - .filter((fb) => fb.project_fingerprint === vulnerability.project_fingerprint) + .filter((fb) => + // Some records still have a `finding_uuid` with null, we need to fallback to using `project_fingerprint` in those cases. Once all entries have been fixed, we can remove the fallback. + // related epic: https://gitlab.com/groups/gitlab-org/-/epics/2791 + fb.finding_uuid !== null + ? fb.finding_uuid === vulnerability.finding_uuid + : fb.project_fingerprint === vulnerability.project_fingerprint, + ) .reduce((vuln, fb) => { if (fb.feedback_type === FEEDBACK_TYPE_DISMISSAL) { return { diff --git a/app/assets/javascripts/webhooks/components/push_events.vue b/app/assets/javascripts/webhooks/components/push_events.vue index 677f06314e0..91d7e21500a 100644 --- a/app/assets/javascripts/webhooks/components/push_events.vue +++ b/app/assets/javascripts/webhooks/components/push_events.vue @@ -33,7 +33,7 @@ export default { <template> <div> - <gl-form-checkbox v-model="pushEventsData">{{ s__('Webhooks|Push events') }}</gl-form-checkbox> + <gl-form-checkbox v-model="pushEventsData">{{ __('Push events') }}</gl-form-checkbox> <input type="hidden" :value="pushEventsData" name="hook[push_events]" /> <div v-if="pushEventsData" class="gl-pl-6"> diff --git a/app/assets/javascripts/webhooks/constants.js b/app/assets/javascripts/webhooks/constants.js index 6710a418117..96632b47e6b 100644 --- a/app/assets/javascripts/webhooks/constants.js +++ b/app/assets/javascripts/webhooks/constants.js @@ -7,13 +7,13 @@ export const BRANCH_FILTER_REGEX = 'regex'; export const WILDCARD_CODE_STABLE = '*-stable'; export const WILDCARD_CODE_PRODUCTION = 'production/*'; -export const REGEX_CODE = '(feature|hotfix)/*'; +export const REGEX_CODE = '^(feature|hotfix)/'; export const descriptionText = { [BRANCH_FILTER_WILDCARD]: s__( 'Webhooks|Wildcards such as %{WILDCARD_CODE_STABLE} or %{WILDCARD_CODE_PRODUCTION} are supported.', ), - [BRANCH_FILTER_REGEX]: s__('Webhooks|Regex such as %{REGEX_CODE} is supported.'), + [BRANCH_FILTER_REGEX]: s__('Webhooks|Regular expressions such as %{REGEX_CODE} are supported.'), }; export const MASK_ITEM_VALUE_HIDDEN = '************'; diff --git a/app/assets/javascripts/whats_new/components/feature.vue b/app/assets/javascripts/whats_new/components/feature.vue index c954a86e593..044a6db6d93 100644 --- a/app/assets/javascripts/whats_new/components/feature.vue +++ b/app/assets/javascripts/whats_new/components/feature.vue @@ -1,5 +1,6 @@ <script> -import { GlBadge, GlIcon, GlLink, GlSafeHtmlDirective, GlButton } from '@gitlab/ui'; +import { GlBadge, GlIcon, GlLink, GlButton } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { dateInWords, isValidDate } from '~/lib/utils/datetime_utility'; export default { @@ -10,7 +11,7 @@ export default { GlButton, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { feature: { diff --git a/app/assets/javascripts/work_items/components/notes/system_note.vue b/app/assets/javascripts/work_items/components/notes/system_note.vue new file mode 100644 index 00000000000..92a2fcaf1df --- /dev/null +++ b/app/assets/javascripts/work_items/components/notes/system_note.vue @@ -0,0 +1,229 @@ +<script> +/** + * Common component to render a system note, icon and user information. + * + * This component need not be used with any store neither has any vuex dependency + * + * @example + * <system-note + * :note="{ + * id: String, + * author: Object, + * createdAt: String, + * bodyHtml: String, + * systemNoteIconName: String + * }" + * /> + */ +import { GlButton, GlSkeletonLoader, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import $ from 'jquery'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history'; +import axios from '~/lib/utils/axios_utils'; +import { getLocationHash } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import NoteHeader from '~/notes/components/note_header.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; + +const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; + +export default { + i18n: { + deleteButtonLabel: __('Remove description history'), + }, + name: 'SystemNote', + components: { + GlIcon, + NoteHeader, + TimelineEntryItem, + GlButton, + GlSkeletonLoader, + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml, + }, + mixins: [descriptionVersionHistoryMixin, glFeatureFlagsMixin()], + props: { + note: { + type: Object, + required: true, + }, + }, + data() { + return { + expanded: false, + lines: [], + showLines: false, + loadingDiff: false, + isLoadingDescriptionVersion: false, + }; + }, + computed: { + targetNoteHash() { + return getLocationHash(); + }, + descriptionVersions() { + return []; + }, + noteAnchorId() { + return `note_${this.note.id}`; + }, + isTargetNote() { + return this.targetNoteHash === this.noteAnchorId; + }, + toggleIcon() { + return this.expanded ? 'chevron-up' : 'chevron-down'; + }, + // following 2 methods taken from code in `collapseLongCommitList` of notes.js: + actionTextHtml() { + return $(this.note.bodyHtml).unwrap().html(); + }, + hasMoreCommits() { + return $(this.note.bodyHtml).filter('ul').children().length > MAX_VISIBLE_COMMIT_LIST_COUNT; + }, + descriptionVersion() { + return this.descriptionVersions[this.note.description_version_id]; + }, + }, + mounted() { + renderGFM(this.$refs['gfm-content']); + }, + methods: { + fetchDescriptionVersion() {}, + softDeleteDescriptionVersion() {}, + + async toggleDiff() { + this.showLines = !this.showLines; + + if (!this.lines.length) { + this.loadingDiff = true; + const { data } = await axios.get(this.note.outdated_line_change_path); + + this.lines = data.map((l) => ({ + ...l, + rich_text: l.rich_text.replace(/^[+ -]/, ''), + })); + this.loadingDiff = false; + } + }, + }, + safeHtmlConfig: { + ADD_TAGS: ['use'], // to support icon SVGs + }, + userColorSchemeClass: window.gon.user_color_scheme, +}; +</script> + +<template> + <timeline-entry-item + :id="noteAnchorId" + :class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }" + class="note system-note note-wrapper" + > + <div class="timeline-icon"><gl-icon :name="note.systemNoteIconName" /></div> + <div class="timeline-content"> + <div class="note-header"> + <note-header + :author="note.author" + :created-at="note.createdAt" + :note-id="note.id" + :is-system-note="true" + > + <span ref="gfm-content" v-safe-html="actionTextHtml"></span> + <template + v-if="canSeeDescriptionVersion || note.outdated_line_change_path" + #extra-controls + > + · + <gl-button + v-if="canSeeDescriptionVersion" + variant="link" + :icon="descriptionVersionToggleIcon" + data-testid="compare-btn" + class="gl-vertical-align-text-bottom gl-font-sm!" + @click="toggleDescriptionVersion" + >{{ __('Compare with previous version') }}</gl-button + > + <gl-button + v-if="note.outdated_line_change_path" + :icon="showLines ? 'chevron-up' : 'chevron-down'" + variant="link" + data-testid="outdated-lines-change-btn" + class="gl-vertical-align-text-bottom gl-font-sm!" + @click="toggleDiff" + > + {{ __('Compare changes') }} + </gl-button> + </template> + </note-header> + </div> + <div class="note-body"> + <div + v-safe-html="note.bodyHtml" + :class="{ 'system-note-commit-list': hasMoreCommits, 'hide-shade': expanded }" + class="note-text md" + ></div> + <div v-if="hasMoreCommits" class="flex-list"> + <div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded"> + <gl-icon :name="toggleIcon" :size="8" class="gl-mr-2" /> + <span>{{ __('Toggle commit list') }}</span> + </div> + </div> + <div v-if="shouldShowDescriptionVersion" class="description-version pt-2"> + <pre v-if="isLoadingDescriptionVersion" class="loading-state"> + <gl-skeleton-loader /> + </pre> + <pre v-else v-safe-html="descriptionVersion" class="wrapper mt-2"></pre> + <gl-button + v-if="displayDeleteButton" + v-gl-tooltip + :title="$options.i18n.deleteButtonLabel" + :aria-label="$options.i18n.deleteButtonLabel" + variant="default" + category="tertiary" + icon="remove" + class="delete-description-history" + data-testid="delete-description-version-button" + @click="deleteDescriptionVersion" + /> + </div> + <div + v-if="lines.length && showLines" + class="diff-content outdated-lines-wrapper gl-border-solid gl-border-1 gl-border-gray-200 gl-mt-4 gl-rounded-small gl-overflow-hidden" + > + <table + :class="$options.userColorSchemeClass" + class="code js-syntax-highlight" + data-testid="outdated-lines" + > + <tr v-for="line in lines" v-once :key="line.line_code" class="line_holder"> + <td + :class="line.type" + class="diff-line-num old_line gl-border-bottom-0! gl-border-top-0! gl-border-0! gl-rounded-0!" + > + {{ line.old_line }} + </td> + <td + :class="line.type" + class="diff-line-num new_line gl-border-bottom-0! gl-border-top-0!" + > + {{ line.new_line }} + </td> + <td + :class="line.type" + class="line_content gl-display-table-cell! gl-border-0! gl-rounded-0!" + v-html="line.rich_text /* eslint-disable-line vue/no-v-html */" + ></td> + </tr> + </table> + </div> + <div v-else-if="showLines" class="mt-4"> + <gl-skeleton-loader /> + </div> + </div> + </div> + </timeline-entry-item> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue index 4d6a27f61ac..c2980405a19 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -202,6 +202,7 @@ export default { if (!this.allowsMultipleAssignees) { this.localAssignees = assignees.length > 0 ? [assignees[assignees.length - 1]] : []; this.isEditing = false; + this.setAssignees(this.assigneeIds); return; } this.localAssignees = assignees; diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue index 57930951856..07da0279b41 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlFormGroup } from '@gitlab/ui'; +import { GlAlert, GlButton, GlFormGroup } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { helpPagePath } from '~/helpers/help_page_helper'; import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave'; @@ -19,6 +19,7 @@ import WorkItemDescriptionRendered from './work_item_description_rendered.vue'; export default { components: { EditedAt, + GlAlert, GlButton, GlFormGroup, MarkdownEditor, @@ -54,6 +55,7 @@ export default { isSubmittingWithKeydown: false, descriptionText: '', descriptionHtml: '', + conflictedDescription: '', }; }, apollo: { @@ -68,11 +70,17 @@ export default { return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; }, skip() { - return !this.workItemId; + return !this.queryVariables.id && !this.queryVariables.iid; }, result() { - this.descriptionText = this.workItemDescription?.description; - this.descriptionHtml = this.workItemDescription?.descriptionHtml; + if (this.isEditing) { + if (this.descriptionText !== this.workItemDescription?.description) { + this.conflictedDescription = this.workItemDescription?.description; + } + } else { + this.descriptionText = this.workItemDescription?.description; + this.descriptionHtml = this.workItemDescription?.descriptionHtml; + } }, error() { this.$emit('error', i18n.fetchError); @@ -94,6 +102,9 @@ export default { canEdit() { return this.workItem?.userPermissions?.updateWorkItem || false; }, + hasConflicts() { + return Boolean(this.conflictedDescription); + }, tracking() { return { category: TRACKING_CATEGORY_SHOW, @@ -196,6 +207,7 @@ export default { this.isEditing = false; clearDraft(this.autosaveKey); + this.conflictedDescription = ''; } catch (error) { this.$emit('error', error.message); Sentry.captureException(error); @@ -224,7 +236,7 @@ export default { label-for="work-item-description" > <markdown-editor - v-if="glFeatures.workItemsMvc2" + v-if="glFeatures.workItemsMvc" class="gl-my-3 common-note-form" :value="descriptionText" :render-markdown-path="markdownPreviewPath" @@ -235,6 +247,7 @@ export default { form-field-name="work-item-description" enable-autocomplete init-on-autofocus + use-bottom-toolbar @input="setDescriptionText" @keydown.meta.enter="updateWorkItem" @keydown.ctrl.enter="updateWorkItem" @@ -246,7 +259,7 @@ export default { :is-submitting="isSubmitting" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="$options.markdownDocsPath" - class="gl-p-3 bordered-box gl-mt-5" + class="gl-px-3 bordered-box gl-mt-5" > <template #textarea> <textarea @@ -267,17 +280,59 @@ export default { </template> </markdown-field> <div class="gl-display-flex"> - <gl-button - category="primary" - variant="confirm" - :loading="isSubmitting" - data-testid="save-description" - @click="updateWorkItem" - >{{ __('Save') }} - </gl-button> - <gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" @click="cancelEditing" - >{{ __('Cancel') }} - </gl-button> + <gl-alert + v-if="hasConflicts" + :dismissible="false" + variant="danger" + class="gl-w-full" + data-testid="work-item-description-conflicts" + > + <p> + {{ + s__( + "WorkItem|Someone edited the description at the same time you did. If you save it will overwrite their changes. Please confirm you'd like to save your edits.", + ) + }} + </p> + <details class="gl-mb-5"> + <summary class="gl-text-blue-500">{{ s__('WorkItem|View current version') }}</summary> + <textarea + class="note-textarea js-gfm-input js-autosize markdown-area gl-p-3" + readonly + :value="conflictedDescription" + ></textarea> + </details> + <template #actions> + <gl-button + category="primary" + variant="confirm" + :loading="isSubmitting" + data-testid="save-description" + @click="updateWorkItem" + >{{ s__('WorkItem|Save and overwrite') }} + </gl-button> + <gl-button + category="secondary" + class="gl-ml-3" + data-testid="cancel" + @click="cancelEditing" + >{{ s__('WorkItem|Discard changes') }} + </gl-button> + </template> + </gl-alert> + <template v-else> + <gl-button + category="primary" + variant="confirm" + :loading="isSubmitting" + data-testid="save-description" + @click="updateWorkItem" + >{{ __('Save') }} + </gl-button> + <gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" @click="cancelEditing" + >{{ __('Cancel') }} + </gl-button> + </template> </div> </gl-form-group> <work-item-description-rendered diff --git a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue index e6f8a301c5e..d58983c013d 100644 --- a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue +++ b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue @@ -1,13 +1,14 @@ <script> -import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui'; -import $ from 'jquery'; -import '~/behaviors/markdown/render_gfm'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; const isCheckbox = (target) => target?.classList.contains('task-list-item-checkbox'); export default { directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, + GlTooltip: GlTooltipDirective, }, components: { GlButton, @@ -45,7 +46,7 @@ export default { async renderGFM() { await this.$nextTick(); - $(this.$refs['gfm-content']).renderGFM(); + renderGFM(this.$refs['gfm-content']); if (this.canEdit) { this.checkboxes = this.$el.querySelectorAll('.task-list-item-checkbox'); @@ -93,14 +94,16 @@ export default { <template> <div class="gl-mb-5 gl-border-t gl-pt-5"> - <div class="gl-display-inline-flex gl-align-items-center gl-mb-5"> + <div class="gl-display-inline-flex gl-align-items-center gl-mb-3"> <label class="d-block col-form-label gl-mr-5">{{ __('Description') }}</label> <gl-button v-if="canEdit" + v-gl-tooltip class="gl-ml-auto" icon="pencil" data-testid="edit-description" :aria-label="__('Edit description')" + :title="__('Edit description')" @click="$emit('startEditing')" /> </div> @@ -111,6 +114,7 @@ export default { ref="gfm-content" v-safe-html="descriptionHtml" class="md gl-mb-5 gl-min-h-8" + data-testid="work-item-description" @change="toggleCheckboxes" ></div> </div> diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index 7e9fa24e3f5..cb45a05de89 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -1,5 +1,6 @@ <script> import { isEmpty } from 'lodash'; +import { produce } from 'immer'; import { GlAlert, GlSkeletonLoader, @@ -11,10 +12,11 @@ import { GlEmptyState, } from '@gitlab/ui'; import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg'; +import * as Sentry from '@sentry/browser'; import { s__ } from '~/locale'; import { parseBoolean } from '~/lib/utils/common_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import { i18n, @@ -23,10 +25,14 @@ import { WIDGET_TYPE_DESCRIPTION, WIDGET_TYPE_START_AND_DUE_DATE, WIDGET_TYPE_WEIGHT, + WIDGET_TYPE_PROGRESS, WIDGET_TYPE_HIERARCHY, - WORK_ITEM_VIEWED_STORAGE_KEY, WIDGET_TYPE_MILESTONE, WIDGET_TYPE_ITERATION, + WIDGET_TYPE_HEALTH_STATUS, + WORK_ITEM_TYPE_VALUE_ISSUE, + WORK_ITEM_TYPE_VALUE_OBJECTIVE, + WIDGET_TYPE_NOTES, } from '../constants'; import workItemDatesSubscription from '../graphql/work_item_dates.subscription.graphql'; @@ -37,6 +43,7 @@ import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql'; import { getWorkItemQuery } from '../utils'; +import WorkItemTree from './work_item_links/work_item_tree.vue'; import WorkItemActions from './work_item_actions.vue'; import WorkItemState from './work_item_state.vue'; import WorkItemTitle from './work_item_title.vue'; @@ -45,7 +52,7 @@ import WorkItemDueDate from './work_item_due_date.vue'; import WorkItemAssignees from './work_item_assignees.vue'; import WorkItemLabels from './work_item_labels.vue'; import WorkItemMilestone from './work_item_milestone.vue'; -import WorkItemInformation from './work_item_information.vue'; +import WorkItemNotes from './work_item_notes.vue'; export default { i18n, @@ -68,11 +75,14 @@ export default { WorkItemTitle, WorkItemState, WorkItemWeight: () => import('ee_component/work_items/components/work_item_weight.vue'), - WorkItemInformation, - LocalStorageSync, + WorkItemProgress: () => import('ee_component/work_items/components/work_item_progress.vue'), WorkItemTypeIcon, WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'), + WorkItemHealthStatus: () => + import('ee_component/work_items/components/work_item_health_status.vue'), WorkItemMilestone, + WorkItemTree, + WorkItemNotes, }, mixins: [glFeatureFlagMixin()], inject: ['fullPath'], @@ -87,7 +97,7 @@ export default { required: false, default: null, }, - iid: { + workItemIid: { type: String, required: false, default: null, @@ -103,7 +113,6 @@ export default { error: undefined, updateError: undefined, workItem: {}, - showInfoBanner: true, updateInProgress: false, }; }, @@ -201,17 +210,31 @@ export default { fullPath() { return this.workItem?.project.fullPath; }, + workItemsMvcEnabled() { + return this.glFeatures.workItemsMvc; + }, workItemsMvc2Enabled() { return this.glFeatures.workItemsMvc2; }, parentWorkItem() { return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY)?.parent; }, + parentWorkItemType() { + return this.parentWorkItem?.workItemType?.name; + }, + parentWorkItemIconName() { + return this.parentWorkItem?.workItemType?.iconName; + }, parentWorkItemConfidentiality() { return this.parentWorkItem?.confidential; }, parentUrl() { - return `../../issues/${this.parentWorkItem?.iid}`; + // Once more types are moved to have Work Items involved + // we need to handle this properly. + if (this.parentWorkItemType === WORK_ITEM_TYPE_VALUE_ISSUE) { + return `../../issues/${this.parentWorkItem?.iid}`; + } + return this.parentWorkItem?.webUrl; }, workItemIconName() { return this.workItem?.workItemType?.iconName; @@ -234,41 +257,48 @@ export default { workItemWeight() { return this.isWidgetPresent(WIDGET_TYPE_WEIGHT); }, + workItemProgress() { + return this.isWidgetPresent(WIDGET_TYPE_PROGRESS); + }, workItemHierarchy() { return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY); }, workItemIteration() { return this.isWidgetPresent(WIDGET_TYPE_ITERATION); }, + workItemHealthStatus() { + return this.isWidgetPresent(WIDGET_TYPE_HEALTH_STATUS); + }, workItemMilestone() { return this.isWidgetPresent(WIDGET_TYPE_MILESTONE); }, + workItemNotes() { + return this.isWidgetPresent(WIDGET_TYPE_NOTES); + }, fetchByIid() { - return this.glFeatures.useIidInWorkItemsPath && parseBoolean(this.$route.query.iid_path); + return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path')); }, queryVariables() { return this.fetchByIid ? { fullPath: this.fullPath, - iid: this.iid, + iid: this.workItemIid, } : { id: this.workItemId, }; }, - }, - beforeDestroy() { - /** make sure that if the user has not even dismissed the alert , - * should no be able to see the information next time and update the local storage * */ - this.dismissBanner(); + children() { + const widgetHierarchy = this.workItem.widgets.find( + (widget) => widget.type === WIDGET_TYPE_HIERARCHY, + ); + return widgetHierarchy.children.nodes; + }, }, methods: { isWidgetPresent(type) { return this.workItem?.widgets?.find((widget) => widget.type === type); }, - dismissBanner() { - this.showInfoBanner = false; - }, toggleConfidentiality(confidentialStatus) { this.updateInProgress = true; let updateMutation = updateWorkItemMutation; @@ -321,8 +351,76 @@ export default { this.error = this.$options.i18n.fetchError; document.title = s__('404|Not found'); }, + addChild(child) { + const { defaultClient: client } = this.$apollo.provider.clients; + this.toggleChildFromCache(child, child.id, client); + }, + toggleChildFromCache(workItem, childId, store) { + const sourceData = store.readQuery({ + query: getWorkItemQuery(this.fetchByIid), + variables: this.queryVariables, + }); + + const newData = produce(sourceData, (draftState) => { + const widgetHierarchy = draftState.workItem.widgets.find( + (widget) => widget.type === WIDGET_TYPE_HIERARCHY, + ); + + const index = widgetHierarchy.children.nodes.findIndex((child) => child.id === childId); + + if (index >= 0) { + widgetHierarchy.children.nodes.splice(index, 1); + } else { + widgetHierarchy.children.nodes.unshift(workItem); + } + }); + + store.writeQuery({ + query: getWorkItemQuery(this.fetchByIid), + variables: this.queryVariables, + data: newData, + }); + }, + async updateWorkItem(workItem, childId, parentId) { + return this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { input: { id: childId, hierarchyWidget: { parentId } } }, + update: (store) => this.toggleChildFromCache(workItem, childId, store), + }); + }, + async undoChildRemoval(workItem, childId) { + try { + const { data } = await this.updateWorkItem(workItem, childId, this.workItem.id); + + if (data.workItemUpdate.errors.length === 0) { + this.activeToast?.hide(); + } + } catch (error) { + this.updateError = s__('WorkItem|Something went wrong while undoing child removal.'); + Sentry.captureException(error); + } finally { + this.activeToast?.hide(); + } + }, + async removeChild(childId) { + try { + const { data } = await this.updateWorkItem(null, childId, null); + + if (data.workItemUpdate.errors.length === 0) { + this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), { + action: { + text: s__('WorkItem|Undo'), + onClick: this.undoChildRemoval.bind(this, data.workItemUpdate.workItem, childId), + }, + }); + } + } catch (error) { + this.updateError = s__('WorkItem|Something went wrong while removing child.'); + Sentry.captureException(error); + } + }, }, - WORK_ITEM_VIEWED_STORAGE_KEY, + WORK_ITEM_TYPE_VALUE_OBJECTIVE, }; </script> @@ -347,14 +445,14 @@ export default { <div class="gl-display-flex gl-align-items-center" data-testid="work-item-body"> <ul v-if="parentWorkItem" - class="list-unstyled gl-display-flex gl-mr-auto gl-max-w-26 gl-md-max-w-50p gl-min-w-0 gl-mb-0" + class="list-unstyled gl-display-flex gl-mr-auto gl-max-w-26 gl-md-max-w-50p gl-min-w-0 gl-mb-0 gl-z-index-0" data-testid="work-item-parent" > <li class="gl-ml-n4 gl-display-flex gl-align-items-center gl-overflow-hidden"> <gl-button v-gl-tooltip.hover class="gl-text-truncate gl-max-w-full" - icon="issues" + :icon="parentWorkItemIconName" category="tertiary" :href="parentUrl" :title="parentWorkItem.title" @@ -411,16 +509,6 @@ export default { @click="$emit('close')" /> </div> - <local-storage-sync - v-model="showInfoBanner" - :storage-key="$options.WORK_ITEM_VIEWED_STORAGE_KEY" - > - <work-item-information - v-if="showInfoBanner && !error" - :show-info-banner="showInfoBanner" - @work-item-banner-dismissed="dismissBanner" - /> - </local-storage-sync> <work-item-title v-if="workItem.title" :work-item-id="workItem.id" @@ -465,19 +553,17 @@ export default { :work-item-type="workItemType" @error="updateError = $event" /> - <template v-if="workItemsMvc2Enabled"> - <work-item-milestone - v-if="workItemMilestone" - :work-item-id="workItem.id" - :work-item-milestone="workItemMilestone.milestone" - :work-item-type="workItemType" - :fetch-by-iid="fetchByIid" - :query-variables="queryVariables" - :can-update="canUpdate" - :full-path="fullPath" - @error="updateError = $event" - /> - </template> + <work-item-milestone + v-if="workItemMilestone" + :work-item-id="workItem.id" + :work-item-milestone="workItemMilestone.milestone" + :work-item-type="workItemType" + :fetch-by-iid="fetchByIid" + :query-variables="queryVariables" + :can-update="canUpdate" + :full-path="fullPath" + @error="updateError = $event" + /> <work-item-weight v-if="workItemWeight" class="gl-mb-5" @@ -489,20 +575,38 @@ export default { :query-variables="queryVariables" @error="updateError = $event" /> - <template v-if="workItemsMvc2Enabled"> - <work-item-iteration - v-if="workItemIteration" - class="gl-mb-5" - :iteration="workItemIteration.iteration" - :can-update="canUpdate" - :work-item-id="workItem.id" - :work-item-type="workItemType" - :fetch-by-iid="fetchByIid" - :query-variables="queryVariables" - :full-path="fullPath" - @error="updateError = $event" - /> - </template> + <work-item-progress + v-if="workItemProgress" + class="gl-mb-5" + :can-update="canUpdate" + :progress="workItemProgress.progress" + :work-item-id="workItem.id" + :work-item-type="workItemType" + :fetch-by-iid="fetchByIid" + :query-variables="queryVariables" + @error="updateError = $event" + /> + <work-item-iteration + v-if="workItemIteration" + class="gl-mb-5" + :iteration="workItemIteration.iteration" + :can-update="canUpdate" + :work-item-id="workItem.id" + :work-item-type="workItemType" + :fetch-by-iid="fetchByIid" + :query-variables="queryVariables" + :full-path="fullPath" + @error="updateError = $event" + /> + <work-item-health-status + v-if="workItemHealthStatus" + class="gl-mb-5" + :health-status="workItemHealthStatus.healthStatus" + :can-update="canUpdate" + :work-item-id="workItem.id" + :work-item-type="workItemType" + @error="updateError = $event" + /> <work-item-description v-if="hasDescriptionWidget" :work-item-id="workItem.id" @@ -512,6 +616,27 @@ export default { class="gl-pt-5" @error="updateError = $event" /> + <work-item-tree + v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE" + :work-item-type="workItemType" + :work-item-id="workItem.id" + :children="children" + :can-update="canUpdate" + :project-path="fullPath" + @addWorkItemChild="addChild" + @removeChild="removeChild" + /> + <template v-if="workItemsMvc2Enabled"> + <work-item-notes + v-if="workItemNotes" + :work-item-id="workItem.id" + :query-variables="queryVariables" + :full-path="fullPath" + :fetch-by-iid="fetchByIid" + class="gl-pt-5" + @error="updateError = $event" + /> + </template> <gl-empty-state v-if="error" :title="$options.i18n.fetchErrorTitle" diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue index 39a662a6c54..e8726814aaf 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue @@ -20,6 +20,11 @@ export default { required: false, default: null, }, + workItemIid: { + type: String, + required: false, + default: null, + }, issueGid: { type: String, required: false, @@ -134,6 +139,7 @@ export default { size="lg" modal-id="work-item-detail-modal" header-class="gl-p-0 gl-pb-2!" + scrollable @hide="closeModal" > <gl-alert v-if="error" variant="danger" @dismiss="error = false"> @@ -144,6 +150,7 @@ export default { is-modal :work-item-parent-id="issueGid" :work-item-id="workItemId" + :work-item-iid="workItemIid" class="gl-p-5 gl-mt-n3" @close="hide" @deleteWorkItem="deleteWorkItem" diff --git a/app/assets/javascripts/work_items/components/work_item_information.vue b/app/assets/javascripts/work_items/components/work_item_information.vue deleted file mode 100644 index ce75cc98a75..00000000000 --- a/app/assets/javascripts/work_items/components/work_item_information.vue +++ /dev/null @@ -1,53 +0,0 @@ -<script> -import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import { helpPagePath } from '~/helpers/help_page_helper'; - -export default { - i18n: { - learnTasksLinkText: s__('WorkItem|Learn about tasks.'), - tasksInformationTitle: s__('WorkItem|Introducing tasks'), - tasksInformationBody: s__( - 'WorkItem|Use tasks to break down your work in an issue into smaller pieces. %{learnMoreLink}', - ), - }, - helpPageLinks: { - tasksDocLinkPath: helpPagePath('user/tasks'), - }, - components: { - GlAlert, - GlSprintf, - GlLink, - }, - props: { - showInfoBanner: { - type: Boolean, - required: false, - default: true, - }, - }, - emits: ['work-item-banner-dismissed'], -}; -</script> - -<template> - <section class="gl-display-block gl-mb-2"> - <gl-alert - v-if="showInfoBanner" - variant="tip" - :title="$options.i18n.tasksInformationTitle" - data-testid="work-item-information" - class="gl-mt-3" - @dismiss="$emit('work-item-banner-dismissed')" - > - <gl-sprintf :message="$options.i18n.tasksInformationBody"> - <template #learnMoreLink> - <gl-link :href="$options.helpPageLinks.tasksDocLinkPath">{{ - $options.i18n.learnTasksLinkText - }}</gl-link> - </template> - ></gl-sprintf - > - </gl-alert> - </section> -</template> diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue index 22af3c653e9..45fb0f7f21a 100644 --- a/app/assets/javascripts/work_items/components/work_item_labels.vue +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -3,8 +3,8 @@ import { GlTokenSelector, GlLabel, GlSkeletonLoader } from '@gitlab/ui'; import { debounce, uniqueId, without } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import Tracking from '~/tracking'; -import labelSearchQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; -import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue'; +import labelSearchQuery from '~/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql'; +import LabelItem from '~/sidebar/components/labels/labels_select_widget/label_item.vue'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; import workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql'; @@ -83,7 +83,7 @@ export default { return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; }, skip() { - return !this.workItemId; + return !this.queryVariables.id && !this.queryVariables.iid; }, error() { this.$emit('error', i18n.fetchError); diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js index 0251dcc33fa..edad0e9b616 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/index.js +++ b/app/assets/javascripts/work_items/components/work_item_links/index.js @@ -17,6 +17,7 @@ export default function initWorkItemLinks() { wiHasIssueWeightsFeature, iid, wiHasIterationsFeature, + wiHasIssuableHealthStatusFeature, } = workItemLinksRoot.dataset; // eslint-disable-next-line no-new @@ -33,6 +34,7 @@ export default function initWorkItemLinks() { fullPath: projectPath, hasIssueWeightsFeature: wiHasIssueWeightsFeature, hasIterationsFeature: wiHasIterationsFeature, + hasIssuableHealthStatusFeature: wiHasIssuableHealthStatusFeature, }, render: (createElement) => createElement('work-item-links', { diff --git a/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue b/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue new file mode 100644 index 00000000000..dc5bcdc3dcc --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue @@ -0,0 +1,66 @@ +<script> +import { GlDropdown, GlDropdownSectionHeader, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; + +import { s__ } from '~/locale'; + +const objectiveActionItems = [ + { + title: s__('OKR|New objective'), + eventName: 'showCreateObjectiveForm', + }, + { + title: s__('OKR|Existing objective'), + eventName: 'showAddObjectiveForm', + }, +]; + +const keyResultActionItems = [ + { + title: s__('OKR|New key result'), + eventName: 'showCreateKeyResultForm', + }, + { + title: s__('OKR|Existing key result'), + eventName: 'showAddKeyResultForm', + }, +]; + +export default { + keyResultActionItems, + objectiveActionItems, + components: { + GlDropdown, + GlDropdownSectionHeader, + GlDropdownItem, + GlDropdownDivider, + }, + methods: { + change({ eventName }) { + this.$emit(eventName); + }, + }, +}; +</script> + +<template> + <gl-dropdown :text="__('Add')" size="small" right> + <gl-dropdown-section-header>{{ __('Objective') }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="item in $options.objectiveActionItems" + :key="item.eventName" + @click="change(item)" + > + {{ item.title }} + </gl-dropdown-item> + + <gl-dropdown-divider /> + <gl-dropdown-section-header>{{ __('Key result') }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="item in $options.keyResultActionItems" + :key="item.eventName" + @click="change(item)" + > + {{ item.title }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue index 34874908f9b..763f2f338a3 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue @@ -1,19 +1,35 @@ <script> -import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; -import { STATE_OPEN } from '../../constants'; +import { + STATE_OPEN, + TASK_TYPE_NAME, + WORK_ITEM_TYPE_VALUE_OBJECTIVE, + WIDGET_TYPE_MILESTONE, + WIDGET_TYPE_HIERARCHY, + WIDGET_TYPE_ASSIGNEES, + WIDGET_TYPE_LABELS, + WORK_ITEM_NAME_TO_ICON_MAP, +} from '../../constants'; +import getWorkItemTreeQuery from '../../graphql/work_item_tree.query.graphql'; +import WorkItemLinkChildMetadata from './work_item_link_child_metadata.vue'; import WorkItemLinksMenu from './work_item_links_menu.vue'; +import WorkItemTreeChildren from './work_item_tree_children.vue'; export default { components: { + GlLink, GlButton, GlIcon, RichTimestampTooltip, + WorkItemLinkChildMetadata, WorkItemLinksMenu, + WorkItemTreeChildren, }, directives: { GlTooltip: GlTooltipDirective, @@ -35,16 +51,48 @@ export default { type: Object, required: true, }, + hasIndirectChildren: { + type: Boolean, + required: false, + default: true, + }, + workItemType: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + isExpanded: false, + children: [], + isLoadingChildren: false, + }; }, computed: { + canHaveChildren() { + return this.workItemType === WORK_ITEM_TYPE_VALUE_OBJECTIVE; + }, + allowsScopedLabels() { + return this.getWidgetByType(this.childItem, WIDGET_TYPE_LABELS)?.allowsScopedLabels; + }, isItemOpen() { return this.childItem.state === STATE_OPEN; }, - iconClass() { - return this.isItemOpen ? 'gl-text-green-500' : 'gl-text-blue-500'; + childItemType() { + return this.childItem.workItemType.name; }, iconName() { - return this.isItemOpen ? 'issue-open-m' : 'issue-close'; + if (this.childItemType === TASK_TYPE_NAME) { + return this.isItemOpen ? 'issue-open-m' : 'issue-close'; + } + return WORK_ITEM_NAME_TO_ICON_MAP[this.childItemType]; + }, + iconClass() { + if (this.childItemType === TASK_TYPE_NAME) { + return this.isItemOpen ? 'gl-text-green-500' : 'gl-text-blue-500'; + } + return ''; }, stateTimestamp() { return this.isItemOpen ? this.childItem.createdAt : this.childItem.closedAt; @@ -55,55 +103,161 @@ export default { childPath() { return `/${this.projectPath}/-/work_items/${getIdFromGraphQLId(this.childItem.id)}`; }, + hasChildren() { + return this.getWidgetByType(this.childItem, WIDGET_TYPE_HIERARCHY)?.hasChildren; + }, + chevronType() { + return this.isExpanded ? 'chevron-down' : 'chevron-right'; + }, + chevronTooltip() { + return this.isExpanded ? __('Collapse') : __('Expand'); + }, + hasMetadata() { + return this.milestone || this.assignees.length > 0 || this.labels.length > 0; + }, + milestone() { + return this.getWidgetByType(this.childItem, WIDGET_TYPE_MILESTONE)?.milestone; + }, + assignees() { + return this.getWidgetByType(this.childItem, WIDGET_TYPE_ASSIGNEES)?.assignees?.nodes || []; + }, + labels() { + return this.getWidgetByType(this.childItem, WIDGET_TYPE_LABELS)?.labels?.nodes || []; + }, + }, + methods: { + toggleItem() { + this.isExpanded = !this.isExpanded; + if (this.children.length === 0 && this.hasChildren) { + this.fetchChildren(); + } + }, + getWidgetByType(workItem, widgetType) { + return workItem?.widgets?.find((widget) => widget.type === widgetType); + }, + async fetchChildren() { + this.isLoadingChildren = true; + try { + const { data } = await this.$apollo.query({ + query: getWorkItemTreeQuery, + variables: { + id: this.childItem.id, + }, + }); + this.children = this.getWidgetByType(data?.workItem, WIDGET_TYPE_HIERARCHY).children.nodes; + } catch (error) { + this.isExpanded = !this.isExpanded; + createAlert({ + message: s__('Hierarchy|Something went wrong while fetching children.'), + captureError: true, + error, + }); + } finally { + this.isLoadingChildren = false; + } + }, }, }; </script> <template> - <div - class="gl-relative gl-display-flex gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32" - data-testid="links-child" - > - <div class="gl-overflow-hidden gl-display-flex gl-align-items-center gl-flex-grow-1"> - <span :id="`stateIcon-${childItem.id}`" class="gl-mr-3" data-testid="item-status-icon"> - <gl-icon :name="iconName" :class="iconClass" :aria-label="stateTimestampTypeText" /> - </span> - <rich-timestamp-tooltip - :target="`stateIcon-${childItem.id}`" - :raw-timestamp="stateTimestamp" - :timestamp-type-text="stateTimestampTypeText" - /> - <gl-icon - v-if="childItem.confidential" - v-gl-tooltip.top - name="eye-slash" - class="gl-mr-2 gl-text-orange-500" - data-testid="confidential-icon" - :aria-label="__('Confidential')" - :title="__('Confidential')" - /> - <gl-button - :href="childPath" - category="tertiary" - variant="link" - class="gl-text-truncate gl-max-w-80 gl-text-black-normal!" - @click="$emit('click', childItem.id, $event)" - @mouseover="$emit('mouseover', childItem.id, $event)" - @mouseout="$emit('mouseout', childItem.id, $event)" - > - {{ childItem.title }} - </gl-button> - </div> + <div> <div - v-if="canUpdate" - class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center" + class="gl-display-flex gl-align-items-flex-start gl-mb-3" + :class="{ 'gl-ml-6': canHaveChildren && !hasChildren && hasIndirectChildren }" > - <work-item-links-menu - :work-item-id="childItem.id" - :parent-work-item-id="issuableGid" - data-testid="links-menu" - @removeChild="$emit('remove', childItem.id)" + <gl-button + v-if="hasChildren" + v-gl-tooltip.viewport + :title="chevronTooltip" + :aria-label="chevronTooltip" + :icon="chevronType" + category="tertiary" + :loading="isLoadingChildren" + class="gl-px-0! gl-py-3! gl-mr-3" + data-testid="expand-child" + @click="toggleItem" /> + <div + class="gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-bg-white gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32" + data-testid="links-child" + > + <span + :id="`stateIcon-${childItem.id}`" + class="gl-mr-3" + :class="{ 'gl-display-flex': hasMetadata }" + data-testid="item-status-icon" + > + <gl-icon + class="gl-text-secondary" + :class="iconClass" + :name="iconName" + :aria-label="stateTimestampTypeText" + /> + </span> + <div + class="gl-display-flex gl-flex-grow-1" + :class="{ + 'gl-flex-direction-column gl-align-items-flex-start': hasMetadata, + 'gl-align-items-center': !hasMetadata, + }" + > + <div class="gl-display-flex"> + <rich-timestamp-tooltip + :target="`stateIcon-${childItem.id}`" + :raw-timestamp="stateTimestamp" + :timestamp-type-text="stateTimestampTypeText" + /> + <gl-icon + v-if="childItem.confidential" + v-gl-tooltip.top + name="eye-slash" + class="gl-mr-2 gl-text-orange-500" + data-testid="confidential-icon" + :aria-label="__('Confidential')" + :title="__('Confidential')" + /> + <gl-link + :href="childPath" + class="gl-overflow-wrap-break gl-line-height-normal gl-text-black-normal! gl-font-weight-bold" + data-testid="item-title" + @click="$emit('click', $event)" + @mouseover="$emit('mouseover')" + @mouseout="$emit('mouseout')" + > + {{ childItem.title }} + </gl-link> + </div> + <work-item-link-child-metadata + v-if="hasMetadata" + :allows-scoped-labels="allowsScopedLabels" + :milestone="milestone" + :assignees="assignees" + :labels="labels" + class="gl-mt-3" + /> + </div> + <div + v-if="canUpdate" + class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center" + > + <work-item-links-menu + :work-item-id="childItem.id" + :parent-work-item-id="issuableGid" + data-testid="links-menu" + @removeChild="$emit('removeChild', childItem.id)" + /> + </div> + </div> </div> + <work-item-tree-children + v-if="isExpanded" + :project-path="projectPath" + :can-update="canUpdate" + :work-item-id="issuableGid" + :work-item-type="workItemType" + :children="children" + @removeChild="fetchChildren" + /> </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue new file mode 100644 index 00000000000..7be7e1f3496 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue @@ -0,0 +1,123 @@ +<script> +import { GlLabel, GlAvatar, GlAvatarLink, GlAvatarsInline, GlTooltipDirective } from '@gitlab/ui'; + +import { s__, sprintf } from '~/locale'; +import { isScopedLabel } from '~/lib/utils/common_utils'; + +import ItemMilestone from '~/issuable/components/issue_milestone.vue'; + +export default { + components: { + GlLabel, + GlAvatar, + GlAvatarLink, + GlAvatarsInline, + ItemMilestone, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + allowsScopedLabels: { + type: Boolean, + required: false, + default: false, + }, + milestone: { + type: Object, + required: false, + default: null, + }, + assignees: { + type: Array, + required: false, + default: () => [], + }, + labels: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + assigneesCollapsedTooltip() { + if (this.assignees.length > 2) { + return sprintf(s__('WorkItem|%{count} more assignees'), { + count: this.assignees.length - 2, + }); + } + return ''; + }, + assigneesContainerClass() { + if (this.assignees.length === 2) { + return 'fixed-width-avatars-2'; + } else if (this.assignees.length > 2) { + return 'fixed-width-avatars-3'; + } + return ''; + }, + labelsContainerClass() { + if (this.milestone || this.assignees.length) { + return 'gl-sm-ml-5'; + } + return ''; + }, + }, + methods: { + showScopedLabel(label) { + return isScopedLabel(label) && this.allowsScopedLabels; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-wrap gl-align-items-center"> + <item-milestone + v-if="milestone" + :milestone="milestone" + class="gl-display-flex gl-align-items-center gl-mr-5 gl-max-w-15 gl-text-secondary! gl-cursor-help! gl-text-decoration-none!" + /> + <gl-avatars-inline + v-if="assignees.length" + :avatars="assignees" + :collapsed="true" + :max-visible="2" + :avatar-size="24" + badge-tooltip-prop="name" + :badge-sr-only-text="assigneesCollapsedTooltip" + :class="assigneesContainerClass" + > + <template #avatar="{ avatar }"> + <gl-avatar-link v-gl-tooltip target="blank" :href="avatar.webUrl" :title="avatar.name"> + <gl-avatar :src="avatar.avatarUrl" :size="24" /> + </gl-avatar-link> + </template> + </gl-avatars-inline> + <div v-if="labels.length" class="gl-display-flex gl-flex-wrap" :class="labelsContainerClass"> + <gl-label + v-for="label in labels" + :key="label.id" + :title="label.title" + :background-color="label.color" + :description="label.description" + :scoped="showScopedLabel(label)" + class="gl-mt-2 gl-sm-mt-0 gl-mr-2 gl-mb-auto gl-label-sm" + tooltip-placement="top" + /> + </div> + </div> +</template> + +<style scoped> +/** + * These overrides are needed to address https://gitlab.com/gitlab-org/gitlab-ui/-/issues/865 + */ +.fixed-width-avatars-2 { + width: 42px !important; +} + +.fixed-width-avatars-3 { + width: 67px !important; +} +</style> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue index 3d469b790a1..faadb5fa6fa 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue @@ -9,13 +9,15 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import { produce } from 'immer'; +import { isEmpty } from 'lodash'; import { s__ } from '~/locale'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql'; -import { isMetaKey } from '~/lib/utils/common_utils'; -import { setUrlParams, updateHistory } from '~/lib/utils/url_utility'; +import { isMetaKey, parseBoolean } from '~/lib/utils/common_utils'; +import { setUrlParams, updateHistory, getParameterByName } from '~/lib/utils/url_utility'; import { FORM_TYPES, @@ -26,6 +28,7 @@ import { import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql'; import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; import workItemQuery from '../../graphql/work_item.query.graphql'; +import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; import WorkItemDetailModal from '../work_item_detail_modal.vue'; import WorkItemLinkChild from './work_item_link_child.vue'; import WorkItemLinksForm from './work_item_links_form.vue'; @@ -45,6 +48,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagMixin()], inject: ['projectPath', 'iid'], props: { workItemId: { @@ -72,6 +76,18 @@ export default { error(e) { this.error = e.message || this.$options.i18n.fetchError; }, + async result() { + const { id, iid } = this.childUrlParams; + this.activeChild = this.fetchByIid + ? this.children.find((child) => child.iid === iid) ?? {} + : this.children.find((child) => child.id === id) ?? {}; + await this.$nextTick(); + if (!isEmpty(this.activeChild)) { + this.$refs.modal.show(); + return; + } + this.updateWorkItemIdUrlQuery(); + }, }, parentIssue: { query: getIssueDetailsQuery, @@ -90,7 +106,7 @@ export default { return { isShownAddForm: false, isOpen: true, - activeChildId: null, + activeChild: {}, activeToast: null, prefetchedWorkItem: null, error: undefined, @@ -139,6 +155,29 @@ export default { childrenCountLabel() { return this.isLoading && this.children.length === 0 ? '...' : this.children.length; }, + fetchByIid() { + return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path')); + }, + childUrlParams() { + const params = {}; + if (this.fetchByIid) { + const iid = getParameterByName('work_item_iid'); + if (iid) { + params.iid = iid; + } + } else { + const workItemId = getParameterByName('work_item_id'); + if (workItemId) { + params.id = convertToGraphQLId(TYPE_WORK_ITEM, workItemId); + } + } + return params; + }, + }, + mounted() { + if (!isEmpty(this.childUrlParams)) { + this.addWorkItemQuery(this.childUrlParams); + } }, methods: { toggle() { @@ -159,29 +198,29 @@ export default { const { defaultClient: client } = this.$apollo.provider.clients; this.toggleChildFromCache(child, child.id, client); }, - openChild(childItemId, e) { + openChild(child, e) { if (isMetaKey(e)) { return; } e.preventDefault(); - this.activeChildId = childItemId; + this.activeChild = child; this.$refs.modal.show(); - this.updateWorkItemIdUrlQuery(childItemId); + this.updateWorkItemIdUrlQuery(child); }, - closeModal() { - this.activeChildId = null; - this.updateWorkItemIdUrlQuery(undefined); + async closeModal() { + this.activeChild = {}; + this.updateWorkItemIdUrlQuery(); }, handleWorkItemDeleted(childId) { const { defaultClient: client } = this.$apollo.provider.clients; this.toggleChildFromCache(null, childId, client); this.activeToast = this.$toast.show(s__('WorkItem|Task deleted')); }, - updateWorkItemIdUrlQuery(childItemId) { - updateHistory({ - url: setUrlParams({ work_item_id: getIdFromGraphQLId(childItemId) }), - replace: true, - }); + updateWorkItemIdUrlQuery({ id, iid } = {}) { + const params = this.fetchByIid + ? { work_item_iid: iid } + : { work_item_id: getIdFromGraphQLId(id) }; + updateHistory({ url: setUrlParams(params), replace: true }); }, toggleChildFromCache(workItem, childId, store) { const sourceData = store.readQuery({ @@ -235,16 +274,31 @@ export default { }); } }, - prefetchWorkItem(id) { + addWorkItemQuery({ id, iid }) { + const variables = this.fetchByIid + ? { + fullPath: this.projectPath, + iid, + } + : { + id, + }; + this.$apollo.addSmartQuery('prefetchedWorkItem', { + query() { + return this.fetchByIid ? workItemByIidQuery : workItemQuery; + }, + variables, + update(data) { + return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; + }, + context: { + isSingleRequest: true, + }, + }); + }, + prefetchWorkItem({ id, iid }) { this.prefetch = setTimeout( - () => - this.$apollo.addSmartQuery('prefetchedWorkItem', { - query: workItemQuery, - variables: { - id, - }, - update: (data) => data.workItem, - }), + () => this.addWorkItemQuery({ id, iid }), DEFAULT_DEBOUNCE_AND_THROTTLE_MS, ); }, @@ -355,16 +409,17 @@ export default { :can-update="canUpdate" :issuable-gid="issuableGid" :child-item="child" - @click="openChild" - @mouseover="prefetchWorkItem" + @click="openChild(child, $event)" + @mouseover="prefetchWorkItem(child)" @mouseout="clearPrefetching" - @remove="removeChild" + @removeChild="removeChild" /> <work-item-detail-modal ref="modal" - :work-item-id="activeChildId" + :work-item-id="activeChild.id" + :work-item-iid="activeChild.iid" @close="closeModal" - @workItemDeleted="handleWorkItemDeleted(activeChildId)" + @workItemDeleted="handleWorkItemDeleted(activeChild.id)" /> </template> </div> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue index 095ea86e0d8..5cf0c4154bb 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue @@ -9,7 +9,16 @@ import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_ty import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql'; import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; import createWorkItemMutation from '../../graphql/create_work_item.mutation.graphql'; -import { FORM_TYPES, TASK_TYPE_NAME } from '../../constants'; +import { + FORM_TYPES, + WORK_ITEMS_TYPE_MAP, + WORK_ITEM_TYPE_ENUM_TASK, + I18N_WORK_ITEM_CREATE_BUTTON_LABEL, + I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, + I18N_WORK_ITEM_ADD_BUTTON_LABEL, + I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL, + sprintfWorkItem, +} from '../../constants'; export default { components: { @@ -52,6 +61,11 @@ export default { type: String, required: true, }, + childrenType: { + type: String, + required: false, + default: WORK_ITEM_TYPE_ENUM_TASK, + }, }, apollo: { workItemTypes: { @@ -71,7 +85,7 @@ export default { return { projectPath: this.projectPath, searchTerm: this.search?.title || this.search, - types: ['TASK'], + types: [this.childrenType], in: this.search ? 'TITLE' : undefined, }; }, @@ -79,7 +93,9 @@ export default { return !this.searchStarted; }, update(data) { - return data.workspace.workItems.nodes.filter((wi) => !this.childrenIds.includes(wi.id)); + return data.workspace.workItems.nodes.filter( + (wi) => !this.childrenIds.includes(wi.id) && this.issuableGid !== wi.id, + ); }, }, }, @@ -99,14 +115,14 @@ export default { let workItemInput = { title: this.search?.title || this.search, projectPath: this.projectPath, - workItemTypeId: this.taskWorkItemType, + workItemTypeId: this.childWorkItemType, hierarchyWidget: { parentId: this.issuableGid, }, confidential: this.parentConfidential, }; - if (this.associateMilestone) { + if (this.parentMilestoneId) { workItemInput = { ...workItemInput, milestoneWidget: { @@ -114,46 +130,62 @@ export default { }, }; } + + if (this.associateIteration) { + workItemInput = { + ...workItemInput, + iterationWidget: { + iterationId: this.parentIterationId, + }, + }; + } + return workItemInput; }, + workItemsMvcEnabled() { + return this.glFeatures.workItemsMvc; + }, workItemsMvc2Enabled() { return this.glFeatures.workItemsMvc2; }, isCreateForm() { return this.formType === FORM_TYPES.create; }, + childrenTypeName() { + return WORK_ITEMS_TYPE_MAP[this.childrenType]?.name; + }, addOrCreateButtonLabel() { if (this.isCreateForm) { - return this.$options.i18n.createChildOptionLabel; + return sprintfWorkItem(I18N_WORK_ITEM_CREATE_BUTTON_LABEL, this.childrenTypeName); } else if (this.workItemsToAdd.length > 1) { - return this.$options.i18n.addTasksButtonLabel; + return sprintfWorkItem(I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL, this.childrenTypeName); } - return this.$options.i18n.addTaskButtonLabel; + return sprintfWorkItem(I18N_WORK_ITEM_ADD_BUTTON_LABEL, this.childrenTypeName); }, addOrCreateMethod() { return this.isCreateForm ? this.createChild : this.addChild; }, - taskWorkItemType() { - return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id; + childWorkItemType() { + return this.workItemTypes.find((type) => type.name === this.childrenTypeName)?.id; }, parentIterationId() { return this.parentIteration?.id; }, associateIteration() { - return this.parentIterationId && this.hasIterationsFeature && this.workItemsMvc2Enabled; + return this.parentIterationId && this.hasIterationsFeature; }, parentMilestoneId() { return this.parentMilestone?.id; }, - associateMilestone() { - return this.parentMilestoneId && this.workItemsMvc2Enabled; - }, isSubmitButtonDisabled() { return this.isCreateForm ? this.search.length === 0 : this.workItemsToAdd.length === 0; }, isLoading() { return this.$apollo.queries.availableWorkItems.loading; }, + addInputPlaceholder() { + return sprintfWorkItem(I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, this.childrenTypeName); + }, }, created() { this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); @@ -206,13 +238,6 @@ export default { } else { this.unsetError(); this.$emit('addWorkItemChild', data.workItemCreate.workItem); - /** - * call update mutation only when there is an iteration associated with the issue - */ - // TODO: setting the iteration should be moved to the creation mutation once the backend is done - if (this.associateIteration) { - this.addIterationToWorkItem(data.workItemCreate.workItem.id); - } } }) .catch(() => { @@ -223,19 +248,6 @@ export default { this.childToCreateTitle = null; }); }, - async addIterationToWorkItem(workItemId) { - await this.$apollo.mutate({ - mutation: updateWorkItemMutation, - variables: { - input: { - id: workItemId, - iterationWidget: { - iterationId: this.parentIterationId, - }, - }, - }, - }); - }, setSearchKey(value) { this.search = value; }, @@ -253,17 +265,13 @@ export default { }, i18n: { inputLabel: __('Title'), - addTaskButtonLabel: s__('WorkItem|Add task'), - addTasksButtonLabel: s__('WorkItem|Add tasks'), addChildErrorMessage: s__( 'WorkItem|Something went wrong when trying to add a child. Please try again.', ), - createChildOptionLabel: s__('WorkItem|Create task'), createChildErrorMessage: s__( 'WorkItem|Something went wrong when trying to create a child. Please try again.', ), createPlaceholder: s__('WorkItem|Add a title'), - addPlaceholder: s__('WorkItem|Search existing tasks'), fieldValidationMessage: __('Maximum of 255 characters'), }, }; @@ -296,7 +304,7 @@ export default { v-model="workItemsToAdd" :dropdown-items="availableWorkItems" :loading="isLoading" - :placeholder="$options.i18n.addPlaceholder" + :placeholder="addInputPlaceholder" menu-class="gl-dropdown-menu-wide dropdown-reduced-height gl-min-h-7!" class="gl-mb-4" data-testid="work-item-token-select-input" diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue new file mode 100644 index 00000000000..f06de2ca048 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue @@ -0,0 +1,244 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; +import { __ } from '~/locale'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +import { + FORM_TYPES, + WIDGET_TYPE_HIERARCHY, + WORK_ITEMS_TREE_TEXT_MAP, + WORK_ITEM_TYPE_ENUM_OBJECTIVE, + WORK_ITEM_TYPE_ENUM_KEY_RESULT, + WORK_ITEM_TYPE_VALUE_OBJECTIVE, +} from '../../constants'; +import workItemQuery from '../../graphql/work_item.query.graphql'; +import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; +import OkrActionsSplitButton from './okr_actions_split_button.vue'; +import WorkItemLinksForm from './work_item_links_form.vue'; +import WorkItemLinkChild from './work_item_link_child.vue'; + +export default { + FORM_TYPES, + WORK_ITEMS_TREE_TEXT_MAP, + WORK_ITEM_TYPE_ENUM_OBJECTIVE, + WORK_ITEM_TYPE_ENUM_KEY_RESULT, + components: { + GlButton, + OkrActionsSplitButton, + WorkItemLinksForm, + WorkItemLinkChild, + }, + mixins: [glFeatureFlagMixin()], + props: { + workItemType: { + type: String, + required: true, + }, + workItemId: { + type: String, + required: true, + }, + children: { + type: Array, + required: false, + default: () => [], + }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, + projectPath: { + type: String, + required: true, + }, + }, + data() { + return { + isShownAddForm: false, + isOpen: true, + error: null, + formType: null, + childType: null, + prefetchedWorkItem: null, + }; + }, + computed: { + toggleIcon() { + return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down'; + }, + toggleLabel() { + return this.isOpen ? __('Collapse') : __('Expand'); + }, + fetchByIid() { + return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path')); + }, + childrenIds() { + return this.children.map((c) => c.id); + }, + hasIndirectChildren() { + return this.children + .map( + (child) => child.widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY) || {}, + ) + .some((hierarchy) => hierarchy.hasChildren); + }, + childUrlParams() { + const params = {}; + if (this.fetchByIid) { + const iid = getParameterByName('work_item_iid'); + if (iid) { + params.iid = iid; + } + } else { + const workItemId = getParameterByName('work_item_id'); + if (workItemId) { + params.id = convertToGraphQLId(TYPE_WORK_ITEM, workItemId); + } + } + return params; + }, + }, + mounted() { + if (!isEmpty(this.childUrlParams)) { + this.addWorkItemQuery(this.childUrlParams); + } + }, + methods: { + toggle() { + this.isOpen = !this.isOpen; + }, + showAddForm(formType, childType) { + this.isOpen = true; + this.isShownAddForm = true; + this.formType = formType; + this.childType = childType; + this.$nextTick(() => { + this.$refs.wiLinksForm.$refs.wiTitleInput?.$el.focus(); + }); + }, + hideAddForm() { + this.isShownAddForm = false; + }, + addWorkItemQuery({ id, iid }) { + const variables = this.fetchByIid + ? { + fullPath: this.projectPath, + iid, + } + : { + id, + }; + this.$apollo.addSmartQuery('prefetchedWorkItem', { + query() { + return this.fetchByIid ? workItemByIidQuery : workItemQuery; + }, + variables, + update(data) { + return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; + }, + context: { + isSingleRequest: true, + }, + }); + }, + prefetchWorkItem({ id, iid }) { + if (this.workItemType !== WORK_ITEM_TYPE_VALUE_OBJECTIVE) { + this.prefetch = setTimeout( + () => this.addWorkItemQuery({ id, iid }), + DEFAULT_DEBOUNCE_AND_THROTTLE_MS, + ); + } + }, + clearPrefetching() { + if (this.prefetch) { + clearTimeout(this.prefetch); + } + }, + }, +}; +</script> + +<template> + <div + class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4" + data-testid="work-item-tree" + > + <div + class="gl-px-5 gl-py-3 gl-display-flex gl-justify-content-space-between" + :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }" + > + <div class="gl-display-flex gl-flex-grow-1"> + <h5 class="gl-m-0 gl-line-height-24"> + {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].title }} + </h5> + </div> + <okr-actions-split-button + @showCreateObjectiveForm=" + showAddForm($options.FORM_TYPES.create, $options.WORK_ITEM_TYPE_ENUM_OBJECTIVE) + " + @showAddObjectiveForm=" + showAddForm($options.FORM_TYPES.add, $options.WORK_ITEM_TYPE_ENUM_OBJECTIVE) + " + @showCreateKeyResultForm=" + showAddForm($options.FORM_TYPES.create, $options.WORK_ITEM_TYPE_ENUM_KEY_RESULT) + " + @showAddKeyResultForm=" + showAddForm($options.FORM_TYPES.add, $options.WORK_ITEM_TYPE_ENUM_KEY_RESULT) + " + /> + <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-100 gl-pl-3 gl-ml-3"> + <gl-button + category="tertiary" + size="small" + :icon="toggleIcon" + :aria-label="toggleLabel" + data-testid="toggle-tree" + @click="toggle" + /> + </div> + </div> + <div + v-if="isOpen" + class="gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base" + :class="{ 'gl-p-5 gl-pb-3': !error }" + data-testid="tree-body" + > + <div v-if="!isShownAddForm && !error && children.length === 0" data-testid="tree-empty"> + <p class="gl-mb-3"> + {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].empty }} + </p> + </div> + <work-item-links-form + v-if="isShownAddForm" + ref="wiLinksForm" + data-testid="add-tree-form" + :issuable-gid="workItemId" + :form-type="formType" + :children-type="childType" + :children-ids="childrenIds" + @addWorkItemChild="$emit('addWorkItemChild', $event)" + @cancel="hideAddForm" + /> + <work-item-link-child + v-for="child in children" + :key="child.id" + :project-path="projectPath" + :can-update="canUpdate" + :issuable-gid="workItemId" + :child-item="child" + :work-item-type="workItemType" + :has-indirect-children="hasIndirectChildren" + @mouseover="prefetchWorkItem(child)" + @mouseout="clearPrefetching" + @removeChild="$emit('removeChild', $event)" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue new file mode 100644 index 00000000000..911cac4de88 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue @@ -0,0 +1,68 @@ +<script> +import { createAlert } from '~/flash'; +import { s__ } from '~/locale'; + +import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; + +export default { + components: { + WorkItemLinkChild: () => import('./work_item_link_child.vue'), + }, + props: { + workItemType: { + type: String, + required: true, + }, + workItemId: { + type: String, + required: true, + }, + children: { + type: Array, + required: false, + default: () => [], + }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, + projectPath: { + type: String, + required: true, + }, + }, + methods: { + async updateWorkItem(childId) { + try { + await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { input: { id: childId, hierarchyWidget: { parentId: null } } }, + }); + this.$emit('removeChild'); + } catch (error) { + createAlert({ + message: s__('Hierarchy|Something went wrong while removing a child item.'), + captureError: true, + error, + }); + } + }, + }, +}; +</script> + +<template> + <div class="gl-ml-6"> + <work-item-link-child + v-for="child in children" + :key="child.id" + :project-path="projectPath" + :can-update="canUpdate" + :issuable-gid="workItemId" + :child-item="child" + :work-item-type="workItemType" + @removeChild="updateWorkItem" + /> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_milestone.vue b/app/assets/javascripts/work_items/components/work_item_milestone.vue index a8d3b57aae0..6ed230b8ad4 100644 --- a/app/assets/javascripts/work_items/components/work_item_milestone.vue +++ b/app/assets/javascripts/work_items/components/work_item_milestone.vue @@ -13,6 +13,7 @@ import { debounce } from 'lodash'; import Tracking from '~/tracking'; import { s__, __ } from '~/locale'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { MILESTONE_STATE } from '~/sidebar/constants'; import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import { @@ -118,6 +119,7 @@ export default { return { 'gl-text-gray-500!': this.canUpdate && this.isNoMilestone, 'is-not-focused': !this.isFocused, + 'gl-min-w-20': true, }; }, }, @@ -139,6 +141,7 @@ export default { return { fullPath: this.fullPath, title: this.searchTerm, + state: MILESTONE_STATE.ACTIVE, first: 20, }; }, @@ -214,9 +217,10 @@ export default { <template> <gl-form-group - class="work-item-dropdown" + class="work-item-dropdown gl-flex-nowrap" :label="$options.i18n.MILESTONE" - label-class="gl-pb-0! gl-overflow-wrap-break gl-mt-3" + label-for="milestone-value" + label-class="gl-pb-0! gl-mt-3 gl-overflow-wrap-break" label-cols="3" label-cols-lg="2" > @@ -229,6 +233,8 @@ export default { </span> <gl-dropdown v-else + id="milestone-value" + class="gl-pl-0 gl-max-w-full" :toggle-class="dropdownClasses" :text="dropdownText" :loading="updateInProgress" diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue new file mode 100644 index 00000000000..91e90589a93 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_notes.vue @@ -0,0 +1,109 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import SystemNote from '~/work_items/components/notes/system_note.vue'; +import { i18n, DEFAULT_PAGE_SIZE_NOTES } from '~/work_items/constants'; +import { getWorkItemNotesQuery } from '~/work_items/utils'; + +export default { + i18n: { + ACTIVITY_LABEL: s__('WorkItem|Activity'), + }, + loader: { + repeat: 10, + width: 1000, + height: 40, + }, + components: { + SystemNote, + GlSkeletonLoader, + }, + props: { + workItemId: { + type: String, + required: true, + }, + queryVariables: { + type: Object, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + fetchByIid: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + areNotesLoading() { + return this.$apollo.queries.workItemNotes.loading; + }, + notes() { + return this.workItemNotes?.nodes; + }, + pageInfo() { + return this.workItemNotes?.pageInfo; + }, + }, + apollo: { + workItemNotes: { + query() { + return getWorkItemNotesQuery(this.fetchByIid); + }, + context: { + isSingleRequest: true, + }, + variables() { + return { + ...this.queryVariables, + pageSize: DEFAULT_PAGE_SIZE_NOTES, + }; + }, + update(data) { + const workItemWidgets = this.fetchByIid + ? data.workspace?.workItems?.nodes[0]?.widgets + : data.workItem?.widgets; + return workItemWidgets.find((widget) => widget.type === 'NOTES').discussions || []; + }, + skip() { + return !this.queryVariables.id && !this.queryVariables.iid; + }, + error() { + this.$emit('error', i18n.fetchError); + }, + }, + }, +}; +</script> + +<template> + <div class="gl-border-t gl-mt-5"> + <label class="gl-mb-0">{{ $options.i18n.ACTIVITY_LABEL }}</label> + <div v-if="areNotesLoading" class="gl-mt-5"> + <gl-skeleton-loader + v-for="index in $options.loader.repeat" + :key="index" + :width="$options.loader.width" + :height="$options.loader.height" + preserve-aspect-ratio="xMinYMax meet" + > + <circle cx="20" cy="20" r="16" /> + <rect width="500" x="45" y="15" height="10" rx="4" /> + </gl-skeleton-loader> + </div> + <div v-else class="issuable-discussion gl-mb-5 work-item-notes"> + <template v-if="notes && notes.length"> + <ul class="notes main-notes-list timeline"> + <system-note + v-for="note in notes" + :key="note.notes.nodes[0].id" + :note="note.notes.nodes[0]" + /> + </ul> + </template> + </div> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_type_icon.vue b/app/assets/javascripts/work_items/components/work_item_type_icon.vue index 96a6493357c..32678e29fa4 100644 --- a/app/assets/javascripts/work_items/components/work_item_type_icon.vue +++ b/app/assets/javascripts/work_items/components/work_item_type_icon.vue @@ -33,6 +33,11 @@ export default { }, computed: { iconName() { + // TODO: Remove this once https://gitlab.com/gitlab-org/gitlab-svgs/-/merge_requests/865 + // is merged and updated in GitLab repo. + if (this.workItemIconName === 'issue-type-keyresult') { + return 'issue-type-key-result'; + } return ( this.workItemIconName || WORK_ITEMS_TYPE_MAP[this.workItemType]?.icon || 'issue-type-issue' ); diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 8b47c24de7d..3cd17f4d360 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -16,17 +16,23 @@ export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION'; export const WIDGET_TYPE_LABELS = 'LABELS'; export const WIDGET_TYPE_START_AND_DUE_DATE = 'START_AND_DUE_DATE'; export const WIDGET_TYPE_WEIGHT = 'WEIGHT'; +export const WIDGET_TYPE_PROGRESS = 'PROGRESS'; export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY'; export const WIDGET_TYPE_MILESTONE = 'MILESTONE'; export const WIDGET_TYPE_ITERATION = 'ITERATION'; - -export const WORK_ITEM_VIEWED_STORAGE_KEY = 'gl-show-work-item-banner'; +export const WIDGET_TYPE_NOTES = 'NOTES'; +export const WIDGET_TYPE_HEALTH_STATUS = 'HEALTH_STATUS'; export const WORK_ITEM_TYPE_ENUM_INCIDENT = 'INCIDENT'; export const WORK_ITEM_TYPE_ENUM_ISSUE = 'ISSUE'; export const WORK_ITEM_TYPE_ENUM_TASK = 'TASK'; export const WORK_ITEM_TYPE_ENUM_TEST_CASE = 'TEST_CASE'; export const WORK_ITEM_TYPE_ENUM_REQUIREMENTS = 'REQUIREMENTS'; +export const WORK_ITEM_TYPE_ENUM_OBJECTIVE = 'OBJECTIVE'; +export const WORK_ITEM_TYPE_ENUM_KEY_RESULT = 'KEY_RESULT'; + +export const WORK_ITEM_TYPE_VALUE_ISSUE = 'Issue'; +export const WORK_ITEM_TYPE_VALUE_OBJECTIVE = 'Objective'; export const i18n = { fetchErrorTitle: s__('WorkItem|Work item not found'), @@ -61,6 +67,13 @@ export const I18N_WORK_ITEM_FETCH_ITERATIONS_ERROR = s__( 'WorkItem|Something went wrong when fetching iterations. Please try again.', ); +export const I18N_WORK_ITEM_CREATE_BUTTON_LABEL = s__('WorkItem|Create %{workItemType}'); +export const I18N_WORK_ITEM_ADD_BUTTON_LABEL = s__('WorkItem|Add %{workItemType}'); +export const I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL = s__('WorkItem|Add %{workItemType}s'); +export const I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER = s__( + 'WorkItem|Search existing %{workItemType}s', +); + export const sprintfWorkItem = (msg, workItemTypeArg) => { const workItemType = workItemTypeArg || s__('WorkItem|Work item'); return capitalizeFirstCharacter( @@ -100,11 +113,45 @@ export const WORK_ITEMS_TYPE_MAP = { icon: `issue-type-requirements`, name: s__('WorkItem|Requirements'), }, + [WORK_ITEM_TYPE_ENUM_OBJECTIVE]: { + icon: `issue-type-objective`, + name: s__('WorkItem|Objective'), + }, + [WORK_ITEM_TYPE_ENUM_KEY_RESULT]: { + icon: `issue-type-issue`, + name: s__('WorkItem|Key Result'), + }, +}; + +export const WORK_ITEMS_TREE_TEXT_MAP = { + [WORK_ITEM_TYPE_VALUE_OBJECTIVE]: { + title: s__('WorkItem|Child objectives and key results'), + empty: s__('WorkItem|No objectives or key results are currently assigned.'), + }, + [WORK_ITEM_TYPE_VALUE_ISSUE]: { + title: s__('WorkItem|Tasks'), + empty: s__( + 'WorkItem|No tasks are currently assigned. Use tasks to break down this issue into smaller parts.', + ), + }, +}; + +export const WORK_ITEM_NAME_TO_ICON_MAP = { + Issue: 'issue-type-issue', + Task: 'issue-type-task', + Objective: 'issue-type-objective', + // eslint-disable-next-line @gitlab/require-i18n-strings + 'Key Result': 'issue-type-key-result', }; export const FORM_TYPES = { create: 'create', add: 'add', + [WORK_ITEM_TYPE_ENUM_OBJECTIVE]: { + icon: `issue-type-issue`, + name: s__('WorkItem|Objective'), + }, }; export const DEFAULT_PAGE_SIZE_ASSIGNEES = 10; +export const DEFAULT_PAGE_SIZE_NOTES = 100; diff --git a/app/assets/javascripts/work_items/graphql/discussion.fragment.graphql b/app/assets/javascripts/work_items/graphql/discussion.fragment.graphql new file mode 100644 index 00000000000..62ced6bdfea --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/discussion.fragment.graphql @@ -0,0 +1,12 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + +fragment Discussion on Note { + id + body + bodyHtml + systemNoteIconName + createdAt + author { + ...User + } +} diff --git a/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql b/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql index 58140aff89e..5c93370aac9 100644 --- a/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql @@ -2,4 +2,7 @@ fragment MilestoneFragment on Milestone { expired id title + state + startDate + dueDate } diff --git a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql index 7b63d9c7ca3..7fcf622cdb2 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql @@ -20,9 +20,12 @@ query workItemLinksQuery($id: WorkItemID!) { children { nodes { id + iid confidential workItemType { id + name + iconName } title state diff --git a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql new file mode 100644 index 00000000000..baefcdaea93 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql @@ -0,0 +1,29 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" +#import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/work_items/graphql/milestone.fragment.graphql" + +fragment WorkItemMetadataWidgets on WorkItemWidget { + ... on WorkItemWidgetMilestone { + type + milestone { + ...MilestoneFragment + } + } + ... on WorkItemWidgetAssignees { + type + assignees { + nodes { + ...User + } + } + } + ... on WorkItemWidgetLabels { + type + allowsScopedLabels + labels { + nodes { + ...Label + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql new file mode 100644 index 00000000000..9439f22f955 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql @@ -0,0 +1,27 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "~/work_items/graphql/discussion.fragment.graphql" + +query workItemNotes($id: WorkItemID!, $after: String, $pageSize: Int) { + workItem(id: $id) { + id + iid + widgets { + ... on WorkItemWidgetNotes { + type + discussions(first: $pageSize, after: $after, filter: ONLY_ACTIVITY) { + pageInfo { + ...PageInfo + } + nodes { + id + notes { + nodes { + ...Discussion + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql new file mode 100644 index 00000000000..3e0960f3f54 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql @@ -0,0 +1,32 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "~/work_items/graphql/discussion.fragment.graphql" + +query workItemNotesByIid($fullPath: ID!, $iid: String, $after: String, $pageSize: Int) { + workspace: project(fullPath: $fullPath) { + id + workItems(iid: $iid) { + nodes { + id + iid + widgets { + ... on WorkItemWidgetNotes { + type + discussions(first: $pageSize, after: $after, filter: ONLY_ACTIVITY) { + pageInfo { + ...PageInfo + } + nodes { + id + notes { + nodes { + ...Discussion + } + } + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql new file mode 100644 index 00000000000..006ca29e01c --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql @@ -0,0 +1,53 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" +#import "~/graphql_shared/fragments/user.fragment.graphql" +#import "./work_item_metadata_widgets.fragment.graphql" + +query workItemTreeQuery($id: WorkItemID!) { + workItem(id: $id) { + id + workItemType { + id + name + iconName + } + title + userPermissions { + deleteWorkItem + updateWorkItem + } + confidential + widgets { + type + ... on WorkItemWidgetHierarchy { + type + parent { + id + } + children { + nodes { + id + iid + confidential + workItemType { + id + name + iconName + } + title + state + createdAt + closedAt + widgets { + ... on WorkItemWidgetHierarchy { + type + hasChildren + } + ...WorkItemMetadataWidgets + } + } + } + } + ...WorkItemMetadataWidgets + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql index b9715c21c27..cf3374e1737 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -1,6 +1,7 @@ #import "~/graphql_shared/fragments/label.fragment.graphql" #import "~/graphql_shared/fragments/user.fragment.graphql" #import "~/work_items/graphql/milestone.fragment.graphql" +#import "./work_item_metadata_widgets.fragment.graphql" fragment WorkItemWidgets on WorkItemWidget { ... on WorkItemWidgetDescription { @@ -38,15 +39,39 @@ fragment WorkItemWidgets on WorkItemWidget { } ... on WorkItemWidgetHierarchy { type + hasChildren parent { id iid title confidential + webUrl + workItemType { + id + name + iconName + } } children { nodes { id + confidential + workItemType { + id + name + iconName + } + title + state + createdAt + closedAt + widgets { + ... on WorkItemWidgetHierarchy { + type + hasChildren + } + ...WorkItemMetadataWidgets + } } } } @@ -56,4 +81,7 @@ fragment WorkItemWidgets on WorkItemWidget { ...MilestoneFragment } } + ... on WorkItemWidgetNotes { + type + } } diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index 4fbcdfe2b96..a056fde6928 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -6,7 +6,14 @@ import { createRouter } from './router'; export const initWorkItemsRoot = () => { const el = document.querySelector('#js-work-items'); - const { fullPath, hasIssueWeightsFeature, issuesListPath, hasIterationsFeature } = el.dataset; + const { + fullPath, + hasIssueWeightsFeature, + issuesListPath, + hasIterationsFeature, + hasOkrsFeature, + hasIssuableHealthStatusFeature, + } = el.dataset; return new Vue({ el, @@ -15,9 +22,12 @@ export const initWorkItemsRoot = () => { apolloProvider, provide: { fullPath, + projectPath: fullPath, hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), + hasOkrsFeature: parseBoolean(hasOkrsFeature), issuesListPath, hasIterationsFeature: parseBoolean(hasIterationsFeature), + hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), }, render(createElement) { return createElement(App); diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue index 1c00bd16263..d04d4942253 100644 --- a/app/assets/javascripts/work_items/pages/work_item_root.vue +++ b/app/assets/javascripts/work_items/pages/work_item_root.vue @@ -70,6 +70,10 @@ export default { <template> <div> <gl-alert v-if="error" variant="danger" @dismiss="error = ''">{{ error }}</gl-alert> - <work-item-detail :work-item-id="gid" :iid="id" @deleteWorkItem="deleteWorkItem($event)" /> + <work-item-detail + :work-item-id="gid" + :work-item-iid="id" + @deleteWorkItem="deleteWorkItem($event)" + /> </div> </template> diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js index 17f9c882c2d..e58fd19ea31 100644 --- a/app/assets/javascripts/work_items/utils.js +++ b/app/assets/javascripts/work_items/utils.js @@ -1,6 +1,12 @@ import workItemQuery from './graphql/work_item.query.graphql'; import workItemByIidQuery from './graphql/work_item_by_iid.query.graphql'; +import workItemNotesIdQuery from './graphql/work_item_notes.query.graphql'; +import workItemNotesByIidQuery from './graphql/work_item_notes_by_iid.query.graphql'; export function getWorkItemQuery(isFetchedByIid) { return isFetchedByIid ? workItemByIidQuery : workItemQuery; } + +export function getWorkItemNotesQuery(isFetchedByIid) { + return isFetchedByIid ? workItemNotesByIidQuery : workItemNotesIdQuery; +} diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index 6878e9a10d7..fa5d2bf7972 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -4,20 +4,16 @@ @import './pages/events'; @import './pages/groups'; @import './pages/hierarchy'; -@import './pages/issuable'; @import './pages/issues'; @import './pages/labels'; @import './pages/login'; @import './pages/ml_experiment_tracking'; @import './pages/merge_requests'; -@import './pages/monitor'; @import './pages/note_form'; @import './pages/notes'; @import './pages/pipelines'; @import './pages/profile'; @import './pages/projects'; @import './pages/registry'; -@import './pages/search'; @import './pages/settings'; @import './pages/storage_quota'; -@import './pages/users'; diff --git a/app/assets/stylesheets/components/content_editor.scss b/app/assets/stylesheets/components/content_editor.scss index 1b6a0208ca7..44b06c0ff12 100644 --- a/app/assets/stylesheets/components/content_editor.scss +++ b/app/assets/stylesheets/components/content_editor.scss @@ -130,6 +130,18 @@ background-color: var(--gl-color-chip-color); } +.content-editor-comment { + &::before { + content: '<!--'; + } + + &::after { + content: '-->'; + } +} + + + .bubble-menu-form { width: 320px; } diff --git a/app/assets/stylesheets/components/ref_selector.scss b/app/assets/stylesheets/components/ref_selector.scss index ded911c2492..f7a9367499e 100644 --- a/app/assets/stylesheets/components/ref_selector.scss +++ b/app/assets/stylesheets/components/ref_selector.scss @@ -6,7 +6,7 @@ width: 20rem; &, - .gl-new-dropdown-inner { + .gl-dropdown-inner { max-height: $dropdown-max-height-lg; } } diff --git a/app/assets/stylesheets/fonts.scss b/app/assets/stylesheets/fonts.scss new file mode 100644 index 00000000000..a6ecca88bd4 --- /dev/null +++ b/app/assets/stylesheets/fonts.scss @@ -0,0 +1,32 @@ +/* ------------------------------------------------------- +Inter variable font. + +Usage: + html { font-family: 'GitLab Sans', sans-serif; } +*/ +@font-face { + font-family: 'GitLab Sans'; + font-weight: 100 900; + font-display: optional; + font-style: normal; + font-named-instance: 'Regular'; /* stylelint-disable property-no-unknown */ + src: font-url('gitlab-sans/GitLabSans.woff2') format('woff2'); +} + +/* ------------------------------------------------------- +Monospaced font: JetBrains Mono. + +Usage: + html { font-family: 'JetBrains Mono', sans-serif; } +*/ +@font-face { + font-family: 'JetBrains Mono'; + font-display: optional; + font-style: normal; + src: font-url('jetbrains-mono/JetBrainsMono.woff2') format('woff2'); +} + +:root { + --default-mono-font: 'JetBrains Mono', 'Menlo'; + --default-regular-font: 'GitLab Sans', -apple-system; +} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index be8a890320f..14e756a5c21 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -1,9 +1,9 @@ /** COLORS **/ .cgray { color: $gl-text-color; } -.clgray { color: $common-gray-light; } +.clgray { color: $gray-200; } .cred { color: $red-500; } .cgreen { color: $green-600; } -.cdark { color: $common-gray-dark; } +.cdark { color: $gray-800; } .fwhite { fill: $white; } .fgray { fill: $gray-500; } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index c5a34ca4b31..0acda85f527 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -5,7 +5,7 @@ // for Snippets is introduced and Clone button is relocated, we won't // need this style. // Issue for the refactoring: https://gitlab.com/gitlab-org/gitlab/-/issues/213327 - &.gl-new-dropdown button.dropdown-toggle { + &.gl-dropdown button.dropdown-toggle { @include gl-display-inline-flex; } @@ -41,7 +41,7 @@ max-height: $extended-max-height; // See comment below for explanation - .gl-new-dropdown-inner { + .gl-dropdown-inner { max-height: $extended-max-height - 2px; } } @@ -54,12 +54,12 @@ width: 100%; } - // `GlDropdown` specifies the `max-height` of `.gl-new-dropdown-inner` + // `GlDropdown` specifies the `max-height` of `.gl-dropdown-inner` // as `$dropdown-max-height`, but the `max-height` rule above forces // the parent `.dropdown-menu` to be _slightly_ too small because of // the 1px borders. The workaround below overrides the @gitlab/ui style // to avoid a double scroll bar. - .gl-new-dropdown-inner { + .gl-dropdown-inner { max-height: $dropdown-max-height - 2px; } } @@ -285,7 +285,7 @@ list-style: none; > a, - button, + > button, .gl-button.btn-link, .menu-item { @include dropdown-link; @@ -1027,7 +1027,7 @@ // This class won't be needed once we can add a prop for this in the GitLab UI component // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/966 -.gl-new-dropdown { +.gl-dropdown { .gl-dropdown-menu-wide { width: $gl-dropdown-width-wide; } @@ -1035,7 +1035,7 @@ // This class won't be needed once we can add a prop for this in the GitLab UI component // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/966 -.gl-new-dropdown.gl-dropdown-menu-full-width { +.gl-dropdown.gl-dropdown-menu-full-width { .dropdown-menu { width: 100%; } diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss index 68a3493670d..16ad6f62c64 100644 --- a/app/assets/stylesheets/framework/emojis.scss +++ b/app/assets/stylesheets/framework/emojis.scss @@ -36,7 +36,7 @@ gl-emoji { } } -.emoji-picker .gl-new-dropdown .dropdown-menu { +.emoji-picker .gl-dropdown .dropdown-menu { width: 350px; } @@ -48,6 +48,6 @@ gl-emoji { border-bottom-color: var(--gl-theme-accent, $theme-indigo-500); } -.emoji-picker .gl-new-dropdown-inner > :last-child { +.emoji-picker .gl-dropdown-inner > :last-child { padding-bottom: 0; } diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 37b61d36911..b35175f4ef6 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -252,7 +252,7 @@ z-index: 1; &:hover .clear-search-icon { - color: $common-gray-dark; + color: $gray-800; } } } @@ -433,8 +433,7 @@ .search-token-target-branch { .value { - font-family: $monospace-font; - font-size: $gl-font-size-monospace; + @include gl-font-monospace; } } diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index bba995a6de3..e86edff3f13 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -14,6 +14,28 @@ input[type='text'].danger { text-shadow: 0 1px 1px $white; } +/** + * When form input type is number, Firefox & Safari show the up/down arrows + * on the right side of the input persistently, while Chrome shows it only + * on hover or focus, this fix allows us to hide the arrows in all browsers. + * You can conditionally add/remove `hide-spinners` class to have consistent + * behaviour across browsers. + */ + +/* stylelint-disable property-no-vendor-prefix */ +input[type='number'].hide-spinners { + -moz-appearance: textfield; + appearance: textfield; + + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + -webkit-appearance: none; + appearance: none; + margin: 0; + } +} +/* stylelint-enable property-no-vendor-prefix */ + .datetime-controls { select { width: 100px; @@ -204,6 +226,22 @@ label { } } +.show-password-complexity-errors { + .form-control:not(textarea) { + height: 34px; + } + + .password-complexity-error-outline { + border: 1px solid $red-500; + + &:focus { + box-shadow: 0 0 0 1px $red-500 inset, 0 1px 1px $gl-field-focus-shadow inset, + 0 0 4px 0 $gl-field-focus-shadow-error; + border: 0 none; + } + } +} + .input-icon-wrapper, .select-wrapper { position: relative; diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index ed41d10f3b2..4b1efcc1e9a 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -1,3 +1,7 @@ +$search-input-field-min-width: 320px; +$search-input-field-max-width: 640px; +$search-input-field-x-min-width: 200px; + .navbar-gitlab { padding: 0 16px; z-index: $header-zindex; @@ -76,6 +80,57 @@ .navbar-collapse > ul.nav > li:not(.d-none) { margin: 0 2px; } + + .header-search-new { + max-width: $search-input-field-max-width; + } + + &.header-search-is-active { + .global-search-container { + flex-grow: 1; + } + } + } + + .header-search { + min-width: $search-input-field-min-width; + + // This is a temporary workaround! + // the button in GitLab UI Search components need to be updated to not be the small size + // see in Figma: https://www.figma.com/file/qEddyqCrI7kPSBjGmwkZzQ/Component-library?node-id=43905%3A45540 + .gl-search-box-by-type-clear.btn-sm { + padding: 0.5rem !important; + } + + @include media-breakpoint-between(md, lg) { + min-width: $search-input-field-x-min-width; + } + + &.is-searching { + .in-search-scope-help { + position: absolute; + top: $gl-spacing-scale-2; + right: 2.125rem; + z-index: 2; + } + } + + &.is-not-focused { + .gl-search-box-by-type-clear { + display: none; + } + } + + .keyboard-shortcut-helper { + transform: translateY(calc(50% - 2px)); + box-shadow: none; + border-color: transparent; + } + } + + .header-search-dropdown-menu { + max-height: $dropdown-max-height; + top: 100%; } .navbar-collapse { @@ -555,7 +610,7 @@ } .top-nav-container-view { - .gl-new-dropdown & .gl-search-box-by-type { + .gl-dropdown & .gl-search-box-by-type { @include gl-m-0; } diff --git a/app/assets/stylesheets/framework/kbd.scss b/app/assets/stylesheets/framework/kbd.scss index 7dd0ae47834..16e0214c703 100644 --- a/app/assets/stylesheets/framework/kbd.scss +++ b/app/assets/stylesheets/framework/kbd.scss @@ -1,10 +1,10 @@ kbd { display: inline-block; padding: 3px 5px; - font-size: $gl-font-size-monospace-sm; + @include gl-font-sm; line-height: 10px; color: var(--gray-700, $gray-700); - vertical-align: middle; + vertical-align: unset; background-color: var(--gray-10, $gray-10); border-width: 1px; border-style: solid; diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 47856f1a0d3..628406d5889 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -450,7 +450,7 @@ } @mixin avatar-counter($border-radius: 1em) { - background-color: $gray-darkest; + background-color: $gray-400; color: $white; border: 1px solid $gray-normal; border-radius: $border-radius; diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 7878e08e549..eb34d91476b 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -198,17 +198,17 @@ } } + $line-height: map-get($spacers, 4) + px-to-rem(2px); + &-icon { /** * 2px extra is to give a little more height than needed * to hide timeline line before/after the element starts/ends */ - height: map-get($spacers, 4) + px-to-rem(2px); + height: $line-height; z-index: 1; position: relative; - top: -3px; padding: $gl-padding-4 0; - background-color: $body-bg; &.opened { color: $green-500; @@ -220,7 +220,7 @@ } &-content { - line-height: initial; + line-height: $line-height; margin-left: $gl-padding-8; } } @@ -280,3 +280,639 @@ grid-area: user; } } + +@mixin right-sidebar { + position: fixed; + top: $header-height; + // Default value for CSS var must contain a unit + // stylelint-disable-next-line length-zero-no-unit + bottom: var(--review-bar-height, 0px); + right: 0; + transition: width $gl-transition-duration-medium; + background-color: $white; + z-index: 200; + overflow: hidden; + +} + +.right-sidebar { + &:not(.right-sidebar-merge-requests) { + @include right-sidebar; + } + + &.right-sidebar-merge-requests { + @include media-breakpoint-down(md) { + @include right-sidebar; + z-index: 251; + } + } + + @include media-breakpoint-down(sm) { + z-index: 251; + } + + a:not(.btn) { + color: inherit; + + &:hover { + color: $blue-800; + } + } + + .gl-label .gl-label-link:hover { + color: inherit; + } + + .btn-link { + color: inherit; + } + + .issuable-header-text { + margin-top: 7px; + } + + .gutter-toggle { + display: flex; + align-items: center; + margin-left: 20px; + padding: 4px; + border-radius: 4px; + height: 24px; + + &:hover { + color: $gl-text-color; + background: $gray-50; + } + + &:hover, + &:focus { + text-decoration: none; + } + } + + &.right-sidebar-merge-requests { + .block, + .sidebar-contained-width, + .issuable-sidebar-header { + width: 100%; + } + + .block { + @include media-breakpoint-up(lg) { + padding: $gl-spacing-scale-4 0 $gl-spacing-scale-5; + } + + &.participants { + border-bottom: 0; + } + } + } + + .block, + .sidebar-contained-width, + .issuable-sidebar-header { + @include clearfix; + padding: $gl-spacing-scale-4 0 $gl-spacing-scale-5; + border-bottom: 1px solid $border-gray-normal; + // This prevents the mess when resizing the sidebar + // of elements repositioning themselves.. + width: $gutter-inner-width; + // -- + + &:last-child { + border: 0; + } + + &.assignee { + .author-link { + display: block; + position: relative; + + &:hover { + .author { + text-decoration: underline; + } + } + } + } + + &.time-tracking, + &.participants, + &.subscriptions, + &.with-sub-blocks { + padding-top: $gl-spacing-scale-5; + } + } + + .block-first { + padding-top: 0; + } + + .title { + color: $gl-text-color; + line-height: $gl-line-height-20; + + .avatar { + margin-left: 0; + } + } + + .selectbox { + display: none; + + &.show { + display: block; + } + } + + .btn-clipboard:hover { + color: $gl-text-color; + } + + .issuable-sidebar { + height: 100%; + + &:not(.is-merge-request) { + overflow-y: scroll; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + } + + &.is-merge-request { + @include media-breakpoint-down(sm) { + overflow-y: scroll; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + } + } + } + + &.right-sidebar-expanded { + &:not(.right-sidebar-merge-requests) { + width: $gutter-width; + } + + .value { + line-height: 1; + } + + .issuable-sidebar { + padding: 0 20px; + + &.is-merge-request { + @include media-breakpoint-up(lg) { + padding: 0; + + .issuable-context-form { + --initial-top: calc(#{$header-height} + 76px); + --top: var(--initial-top); + + @include gl-sticky; + @include gl-overflow-auto; + + top: var(--top); + height: calc(100vh - var(--top)); + padding: 0 15px; + margin-bottom: calc(var(--top) * -1); + + .with-performance-bar & { + --top: calc(var(--initial-top) + #{$performance-bar-height}); + } + + .with-system-header & { + --top: calc(var(--initial-top) + #{$system-header-height}); + } + + .with-performance-bar.with-system-header & { + --top: calc(var(--initial-top) + #{$system-header-height} + #{$performance-bar-height}); + } + } + } + } + } + + &:not(.boards-sidebar):not([data-signed-in]):not([data-always-show-toggle]) { + .issuable-sidebar-header { + display: none; + } + } + + .light { + font-weight: $gl-font-weight-normal; + } + + .no-value { + color: $gl-text-color-secondary; + } + + .sidebar-collapsed-icon { + display: none; + } + + .gutter-toggle { + text-align: center; + } + + .title .gutter-toggle { + margin-top: 0; + } + + .assignee .user-list .avatar { + margin: 0; + } + + .hide-expanded { + display: none; + } + } + + &.right-sidebar-collapsed { + /* Extra small devices (phones, less than 768px) */ + display: none; + /* Small devices (tablets, 768px and up) */ + + &:not(.right-sidebar-merge-requests) { + @include media-breakpoint-up(sm) { + display: block; + } + } + + &.right-sidebar-merge-requests { + @include media-breakpoint-up(lg) { + display: block; + } + } + + width: $gutter-collapsed-width; + padding: 0; + + .block, + .sidebar-contained-width, + .issuable-sidebar-header { + width: $gutter-collapsed-width - 2px; + padding: 0; + border-bottom: 0; + overflow: hidden; + } + + .block, + .gutter-toggle, + .sidebar-collapsed-container { + &.with-sub-blocks .sub-block:hover, + &:not(.with-sub-blocks):hover { + background-color: $gray-100; + } + } + + .participants { + border-bottom: 1px solid $border-gray-normal; + } + + .hide-collapsed { + display: none; + } + + .gutter-toggle { + width: 100%; + height: $sidebar-toggle-height; + margin-left: 0; + border-bottom: 1px solid $border-white-normal; + border-radius: 0; + } + + a.gutter-toggle { + display: flex; + justify-content: center; + flex-direction: column; + text-align: center; + } + + .merge-icon { + height: 12px; + width: 12px; + bottom: -5px; + right: 4px; + } + + .sidebar-collapsed-icon { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + height: $sidebar-toggle-height; + text-align: center; + color: $gl-text-color-secondary; + + > svg { + fill: $gl-text-color-secondary; + } + + &:hover:not(.disabled), + &:hover .todo-undone { + color: $gl-text-color; + + > svg { + fill: $gl-text-color; + } + } + + .todo-undone { + color: $blue-600; + fill: $blue-600; + } + + .author { + display: none; + } + + .btn-clipboard { + /* + This change should be temporary, because the DOM currently gets + generated from a ruby definition in `app/helpers/button_helper.rb`. + As soon as the `copy to clipboard` button will be transferred to + Vue this should be adjusted as well. + */ + flex: 1; + align-self: stretch; + padding: 0; + + border: 0; + background: transparent; + color: $gl-text-color-secondary; + + &:hover { + color: $gl-text-color; + } + } + + &.multiple-users { + display: flex; + justify-content: center; + } + } + + .sidebar-avatar-counter { + width: 24px; + height: 24px; + border-radius: 12px; + + ~.merge-icon { + bottom: 0; + } + } + + .sidebar-collapsed-user { + padding-bottom: 0; + + .author-link { + padding-left: 0; + + .avatar { + position: static; + margin: 0; + } + } + } + + .issuable-header-btn { + display: none; + } + + .multiple-users { + .btn-link { + padding: 0; + border: 0; + + .avatar { + margin: 0; + } + } + + .btn-link:first-child { + position: absolute; + left: 10px; + z-index: 1; + } + + .btn-link:last-child { + position: absolute; + right: 10px; + + &:hover { + text-decoration: none; + } + } + } + + .milestone-title span, + .collapse-truncated-title { + @include str-truncated(100%); + display: block; + margin: 0 4px; + } + } + + .dropdown-menu-toggle { + width: 100%; + padding-top: 6px; + } + + .dropdown-menu { + width: 100%; + + /* + * Overwrite hover style for dropdown items, so that they are not blue + * This should be removed during dev of https://gitlab.com/gitlab-org/gitlab-foss/issues/44040 + */ + li a { + &:hover, + &:active, + &:focus, + &.is-focused { + @include dropdown-item-hover; + } + } + + } +} + +.with-performance-bar .right-sidebar { + top: calc(#{$header-height} + #{$performance-bar-height}); +} + +.sidebar-move-issue-confirmation-button { + width: 100%; + + &.is-loading { + .sidebar-move-issue-confirmation-loading-icon { + display: inline-block; + } + } +} + +.sidebar-move-issue-confirmation-loading-icon { + display: none; +} + +.issuable-show-labels { + .gl-label { + margin-bottom: 5px; + margin-right: 5px; + } + + a { + display: inline-block; + + .color-label { + padding: 4px $grid-size; + border-radius: $label-border-radius; + margin-right: 4px; + margin-bottom: 4px; + } + + &:hover .color-label { + text-decoration: underline; + } + } + + &.has-labels { + // this font size is a fix to + // prevent unintended spacing between labels + // which shows up when rendering markup has white-space + // characters present. + // see: https://css-tricks.com/fighting-the-space-between-inline-block-elements/#article-header-id-3 + font-size: 0; + margin-bottom: -5px; + } +} + +.assignee, +.reviewer { + .merge-icon { + color: $orange-400; + position: absolute; + bottom: -3px; + right: -3px; + filter: drop-shadow(0 0 0.5px $white) drop-shadow(0 0 1px $white) drop-shadow(0 0 2px $white); + } +} + +.participants-author { + &:nth-of-type(8n) { + padding-right: 0; + } + + .avatar.avatar-inline { + margin: 0; + } +} + +.participants-more, +.user-list-more { + margin-left: 5px; + + a, + .btn-link { + color: $gl-text-color-secondary; + } + + .btn-link { + padding: 0; + } + + .btn-link:hover { + color: $blue-800; + text-decoration: none; + } + + .btn-link:focus { + text-decoration: none; + } +} + +.sidebar-help-wrap { + .sidebar-help-state { + margin: 16px -20px -20px; + padding: 16px 20px; + } + + .help-state-toggle-enter-active { + transition: all 0.8s ease; + } + + .help-state-toggle-leave-active { + transition: all 0.5s ease; + } + + .help-state-toggle-enter, + .help-state-toggle-leave-active { + opacity: 0; + } +} + +.time-tracker { + .sidebar-collapsed-icon { + > .stopwatch-svg { + display: inline-block; + } + + svg { + width: 16px; + height: 16px; + fill: $gl-text-color-secondary; + } + + &:hover svg { + fill: $gl-text-color; + } + } + + .compare-meter { + &.over_estimate { + .time-remaining, + .compare-value.spent { + color: $red-500; + } + } + } + + .compare-display-container { + font-size: 13px; + } +} + +/* + * Following overrides are done to prevent + * legacy dropdown styles from influencing + * GitLab UI components used within GlDropdown + */ +.right-sidebar-collapsed { + .sidebar-grouped-item { + .sidebar-collapsed-icon { + margin-bottom: 0; + } + + .sidebar-collapsed-divider { + line-height: 5px; + font-size: 12px; + color: $gray-500; + + + .sidebar-collapsed-icon { + padding-top: 0; + } + } + } +} + +@include media-breakpoint-down(sm) { + // Overriding the following rule with the negative margin + // https://gitlab.com/gitlab-org/gitlab/-/blob/146c43c931c3743a140529307aea616e4aa9ff21/app/assets/stylesheets/framework/sidebar.scss#L1-5 + .container-fluid { + .issuable-list, + .issues-filters, + .epics-filters { + margin: 0 (-$gl-padding); + } + } +} diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 2c2d8a2b592..0a475845fd3 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -81,6 +81,7 @@ word-wrap: break-word; overflow-wrap: break-word; word-break: keep-all; + @include gl-font-base; } h1 { @@ -322,7 +323,6 @@ pre { margin-bottom: 16px; - font-size: 13px; line-height: 1.6em; overflow-x: auto; border-radius: $border-radius-default; @@ -587,7 +587,7 @@ } } - .gl-new-dropdown-item { + .gl-dropdown-item { margin: 0; padding: 0; line-height: 1rem; @@ -658,7 +658,7 @@ pre { display: block; padding: $gl-padding-8 $input-horizontal-padding; margin: 0 0 $gl-padding-8; - font-size: $gl-font-size-monospace; + @include gl-font-base; word-break: break-all; word-wrap: break-word; color: $gl-text-color; @@ -680,7 +680,7 @@ code { } .monospace { - font-family: $monospace-font; + @include gl-font-monospace; } .weight-normal { @@ -706,7 +706,7 @@ code { */ textarea.js-gfm-input { font-family: $monospace-font; - font-size: $gl-font-size-monospace; + @include gl-font-base; } h1, @@ -772,3 +772,8 @@ textarea { wbr { display: inline-block; } + +// The font used in Monaco editor - Web IDE, Snippets, single file editor +:root { + --code-editor-font: #{$monospace-font}; +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 99284ea0a64..ec8ffaf8c53 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -83,18 +83,9 @@ $darken-dark-factor: 10% !default; $darken-border-factor: 5% !default; $darken-border-dashed-factor: 25% !default; -$white: #fff !default; -$white-normal: #f0f0f0 !default; -$white-dark: #eaeaea !default; -$white-transparent: rgba($white, 0.8) !default; - $purple: #6d49cb !default; $purple-light: #ede8fb !default; -$black: #000 !default; -$black-transparent: rgba(0, 0, 0, 0.3) !default; -$almost-black: #242424 !default; - $green-50: #ecf4ee !default; $green-100: #c3e6cd !default; $green-200: #91d4a8 !default; @@ -183,6 +174,15 @@ $t-gray-a-08: rgba($gray-950, 0.08) !default; $t-gray-a-16: rgba($gray-950, 0.16) !default; $t-gray-a-24: rgba($gray-950, 0.24) !default; +$white: #fff !default; +$white-normal: $gray-50 !default; +$white-dark: darken($gray-50, 2) !default; +$white-transparent: rgba($white, 0.8) !default; + +$black: #000 !default; +$black-transparent: $t-gray-a-24 !default; +$almost-black: $gray-950 !default; + $greens: ( '50': $green-50, '100': $green-100, @@ -350,17 +350,17 @@ $theme-light-red-700: #a62e21; // Data visualization color palette -$data-viz-blue-50: #e9ebff; -$data-viz-blue-100: #d4dcfa; -$data-viz-blue-200: #b7c6ff; -$data-viz-blue-300: #97acff; -$data-viz-blue-400: #748eff; -$data-viz-blue-500: #5772ff; -$data-viz-blue-600: #445cf2; -$data-viz-blue-700: #3547de; -$data-viz-blue-800: #232fcf; -$data-viz-blue-900: #1e23a8; -$data-viz-blue-950: #11118a; +$data-viz-blue-50: #e9ebff !default; +$data-viz-blue-100: #d2dcff !default; +$data-viz-blue-200: #b7c6ff !default; +$data-viz-blue-300: #97acff !default; +$data-viz-blue-400: #7992f5 !default; +$data-viz-blue-500: #617ae2 !default; +$data-viz-blue-600: #4e65cd !default; +$data-viz-blue-700: #3f51ae !default; +$data-viz-blue-800: #374291 !default; +$data-viz-blue-900: #303470 !default; +$data-viz-blue-950: #2a2b59 !default; $border-white-light: darken($white, $darken-border-factor) !default; $border-white-normal: darken($white-normal, $darken-border-factor) !default; @@ -380,7 +380,7 @@ $well-expand-item: #e8f2f7 !default; $well-inner-border: #eef0f2 !default; $well-light-border: #f1f1f1; $well-light-text-color: #5b6169; -$nav-active-bg: rgba($black, 0.08); +$nav-active-bg: $t-gray-a-08; /* * Text @@ -555,11 +555,13 @@ $diff-jagged-border-gradient-color: darken($white-normal, 8%); /* * Fonts */ -$monospace-font: 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', 'Ubuntu Mono', - 'Courier New', 'andale mono', 'lucida console', monospace; -$regular-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans', Ubuntu, Cantarell, - 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', - 'Noto Color Emoji'; +$monospace-font: var(--default-mono-font, 'Menlo'), 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', + 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace; +$regular-font: var(--default-regular-font, -apple-system), BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans', + Ubuntu, Cantarell, 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; +$gl-monospace-font: $monospace-font; +$gl-regular-font: $regular-font; /* * Dropdowns @@ -730,12 +732,6 @@ $commit-message-text-area-bg: rgba(0, 0, 0, 0); $commit-stat-summary-height: 36px; /* -* Common -*/ -$common-gray-light: #bbb; -$common-gray-dark: #444; - -/* * Files */ $logs-li-color: #888; @@ -784,16 +780,6 @@ $fade-mask-transition-curve: ease-in-out; $login-brand-holder-color: #888; /* -* Projects -*/ -$project-option-descr-color: #54565b; - -/* - * Monitor Charts - */ -$chart-tooltip-max-width: 512px; - -/* Stat Graph */ $stat-graph-common-bg: #f3f3f3; @@ -822,7 +808,6 @@ Pipeline Graph $ci-action-icon-size: 22px; $ci-action-icon-size-lg: 24px; $pipeline-dropdown-line-height: 20px; -$pipeline-dropdown-status-icon-size: 18px; $ci-action-dropdown-button-size: 24px; $ci-action-dropdown-svg-size: 12px; diff --git a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss index 613d27a2f39..ed15e352b7d 100644 --- a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss +++ b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss @@ -33,8 +33,8 @@ .ci-action-icon-container { position: absolute; - right: 8px; - top: 8px; + right: 11px; + top: 7px; &.ci-action-icon-wrapper { height: $ci-action-dropdown-button-size; @@ -84,25 +84,6 @@ &.non-details-job-component { padding: $gl-padding-8 $gl-btn-horz-padding; } - - .ci-job-name-component { - align-items: center; - display: flex; - flex: 1; - } - - .ci-status-icon { - position: relative; - - > svg { - width: $pipeline-dropdown-status-icon-size; - height: $pipeline-dropdown-status-icon-size; - margin: 3px 0; - position: relative; - overflow: visible; - display: block; - } - } } // ensure .mini-pipeline-graph-dropdown-item has hover style when action-icon is hovered diff --git a/app/assets/stylesheets/page_bundles/alert_management_details.scss b/app/assets/stylesheets/page_bundles/alert_management_details.scss index 2eaf4517710..d67dadafa9e 100644 --- a/app/assets/stylesheets/page_bundles/alert_management_details.scss +++ b/app/assets/stylesheets/page_bundles/alert_management_details.scss @@ -28,7 +28,7 @@ @include gl-pt-8; } - .gl-new-dropdown-item-text-wrapper { + .gl-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 0cc1fb40e4a..bdbcf7ab58c 100644 --- a/app/assets/stylesheets/page_bundles/boards.scss +++ b/app/assets/stylesheets/page_bundles/boards.scss @@ -185,13 +185,15 @@ } .issue-boards-content.is-focused { + $focus-mode-z-index: 9000; + position: fixed; width: 100%; height: 100%; top: 0; left: 0; background: var(--white, $white); - z-index: 9000; + z-index: $focus-mode-z-index; @include media-breakpoint-down(sm) { padding-top: 10px; @@ -201,13 +203,24 @@ height: calc(100vh - #{$issue-boards-filter-height}); } - .boards-sidebar { - height: 100%; - top: 0; + // Use !important for these as top and z-index are set on style attribute + // in gitlab-ui; https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1805 + ~ #js-right-sidebar-portal .boards-sidebar { + top: 0 !important; + z-index: calc(#{$focus-mode-z-index} + 1) !important; } } .boards-sidebar { + top: $header-height !important; + height: auto; + bottom: 0; + padding-bottom: 0.5rem; + + .with-performance-bar & { + top: calc(#{$header-height} + #{$performance-bar-height}) !important; + } + .sidebar-collapsed-icon { @include gl-display-none; } diff --git a/app/assets/stylesheets/page_bundles/clusters.scss b/app/assets/stylesheets/page_bundles/clusters.scss index 4f29ff4b1ad..4d75159e87a 100644 --- a/app/assets/stylesheets/page_bundles/clusters.scss +++ b/app/assets/stylesheets/page_bundles/clusters.scss @@ -6,7 +6,7 @@ @include gl-w-full; order: -1; - .gl-new-dropdown, + .gl-dropdown, .split-content-button { @include gl-w-full; } @@ -24,3 +24,9 @@ .cluster-button-container:focus-within { @include gl-focus; } + +.select-agent-dropdown { + .gl-button-text { + @include gl-flex-grow-1; + } +} diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index ec75c53d026..c3688f4a138 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -27,13 +27,6 @@ $ide-commit-header-height: 48px; @include str-truncated(250px); } -.ide-layout { - // Fix for iOS 13+, the height of the page is actually less than - // 100vh because of the presence of the bottom bar - max-height: 100%; - position: fixed; -} - .ide-view { position: relative; margin-top: 0; @@ -522,13 +515,6 @@ $ide-commit-header-height: 48px; } } -.ide-loading { - display: flex; - height: 100%; - align-items: center; - justify-content: center; -} - .ide-empty-state { display: flex; height: 100vh; diff --git a/app/assets/stylesheets/page_bundles/incidents.scss b/app/assets/stylesheets/page_bundles/incidents.scss index de246fa14b9..e807c4c0bbb 100644 --- a/app/assets/stylesheets/page_bundles/incidents.scss +++ b/app/assets/stylesheets/page_bundles/incidents.scss @@ -4,13 +4,10 @@ .main-notes-list::before { content: none; } +} - .timeline-event-note { - p { - margin-bottom: 0; - font-size: 0.875rem; - } - } +.timeline-event { + grid-template-columns: #{$gl-spacing-scale-9} minmax(0, 1fr) #{$gl-spacing-scale-7}; } /** diff --git a/app/assets/stylesheets/page_bundles/issuable.scss b/app/assets/stylesheets/page_bundles/issuable.scss new file mode 100644 index 00000000000..f364170c99f --- /dev/null +++ b/app/assets/stylesheets/page_bundles/issuable.scss @@ -0,0 +1,183 @@ +@import 'mixins_and_variables_and_functions'; + +.status-box { + padding: 0 $gl-btn-padding; + border-radius: $border-radius-default; + display: block; + float: left; + margin-right: $gl-padding-8; + color: var(--white, $white); + font-size: $gl-font-size; + line-height: $gl-line-height-24; +} + +.issuable-warning-icon { + background-color: var(--orange-50, $orange-50); + border-radius: $border-radius-default; + color: var(--orange-600, $orange-600); + width: $issuable-warning-size; + height: $issuable-warning-size; + text-align: center; + margin-right: $issuable-warning-icon-margin; + line-height: $gl-line-height-24; + flex: 0 0 auto; +} + +.limit-container-width { + .flash-container, + .detail-page-header, + .page-content-header, + .commit-box, + .info-well, + .commit-ci-menu, + .files-changed-inner, + .limited-header-width, + .limited-width-notes { + @include fixed-width-container; + } + + .issuable-details { + .detail-page-description, + .mr-source-target, + .mr-state-widget, + .merge-manually { + @include fixed-width-container; + } + } + + .merge-request-details { + .emoji-list-container { + @include fixed-width-container; + } + } +} + +.issuable-details { + section { + .issuable-discussion { + margin-right: 1px; + } + } + + .title-container { + display: flex; + align-items: flex-start; + } + + .title { + padding: 0; + margin-bottom: $gl-padding; + border-bottom: 0; + word-wrap: break-word; + overflow-wrap: break-word; + min-width: 0; + width: 100%; + text-align: initial; + } + + .btn-edit { + margin-left: auto; + } +} + +.detail-page-description { + padding: 16px 0; + + small { + color: var(--gray-500, $gray-500); + } +} + +.edited-text { + color: var(--gray-500, $gray-500); + display: block; + margin: 16px 0 0; + font-size: 85%; + + .author-link { + color: var(--gray-500, $gray-500); + } +} + +.user-item { + padding: 5px; + flex-basis: 20%; + + .user-link { + display: inline-block; + } +} + +.issuable-gutter-toggle { + @include media-breakpoint-down(sm) { + margin-left: $btn-side-margin; + } +} + +.issuable-meta { + flex: 1; + display: inline-block; + font-size: 14px; + align-self: center; + overflow: hidden; + text-overflow: ellipsis; + + .user-status-emoji { + margin-left: $gl-padding-4; + margin-right: 0; + } +} + +.js-issuable-selector-wrap { + .js-issuable-selector { + width: 100%; + } + + @include media-breakpoint-down(sm) { + margin-bottom: $gl-padding; + } +} + +.add-issuable-form-input-wrapper { + &.focus { + border-color: var(--gray-700, $gray-700); + @include gl-focus; + + input { + @include gl-shadow-none; + } + } + + .gl-show-field-errors &.form-control:not(textarea) { + height: auto; + } +} + +/* + * Following overrides are done to prevent + * legacy dropdown styles from influencing + * GitLab UI components used within GlDropdown + */ +.issuable-move-dropdown { + .b-dropdown-form { + @include gl-p-0; + } + + .gl-search-box-by-type button.gl-clear-icon-button:hover { + @include gl-bg-transparent; + } + + .issuable-move-button:not(.disabled):hover { + @include gl-text-white; + } +} + +.suggestion-footer { + font-size: 12px; + line-height: 15px; + + .avatar { + margin-top: -3px; + border: 0; + } +} diff --git a/app/assets/stylesheets/page_bundles/issuable_list.scss b/app/assets/stylesheets/page_bundles/issuable_list.scss new file mode 100644 index 00000000000..b08e129a805 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/issuable_list.scss @@ -0,0 +1,96 @@ +@import 'mixins_and_variables_and_functions'; + +.issuable-list { + li { + .issuable-info-container { + flex: 1; + display: flex; + } + + .issuable-main-info { + flex: 1 auto; + margin-right: 10px; + min-width: 0; + + .issue-weight-icon, + .issue-estimate-icon { + vertical-align: sub; + } + } + + .issuable-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + flex: 1 0 auto; + + .controls { + margin-bottom: 2px; + line-height: 20px; + padding: 0; + } + } + + @include media-breakpoint-down(xs) { + .issuable-meta { + .controls li { + margin-right: 0; + } + } + } + + .issue-check { + min-width: 15px; + } + + .issuable-milestone, + .issuable-info, + .task-status, + .issuable-timestamp { + font-weight: $gl-font-weight-normal; + color: var(--gray-500, $gl-text-color-secondary); + + a { + color: var(--gl-text-color, $gl-text-color); + } + + .gl-label-link { + color: inherit; + + &:hover { + text-decoration: none; + + .gl-label-text:last-of-type { + text-decoration: underline; + } + } + } + + .milestone { + color: var(--gray-700, $gray-700); + } + } + + @media(max-width: map-get($grid-breakpoints, lg)-1) { + .task-status, + .issuable-due-date, + .issuable-weight, + .project-ref-path { + display: none; + } + } + } +} + +.issuable-list li, +.issuable-info-container .controls { + .avatar-counter { + display: inline-block; + vertical-align: middle; + min-width: 16px; + line-height: 14px; + height: 16px; + padding-left: 2px; + padding-right: 2px; + } +} diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index 771428b49e0..4950561bcb7 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -329,6 +329,8 @@ $tabs-holder-z-index: 250; top: 0; // !important is required to override inline styles of resizable sidebar width: 100% !important; + // avoid sticky elements overlap header and other elements + z-index: 1; } .tree-list-holder { @@ -339,11 +341,13 @@ $tabs-holder-z-index: 250; } .ci-widget-container { + align-items: center; justify-content: space-between; flex: 1; flex-direction: row; @include media-breakpoint-down(sm) { + align-items: initial; flex-direction: column; .dropdown .mini-pipeline-graph-dropdown-menu.dropdown-menu { @@ -632,13 +636,6 @@ $tabs-holder-z-index: 250; margin: 3px 0; } - .ci-status-icon svg { - margin: 3px 0; - position: relative; - overflow: visible; - display: block; - } - .normal { flex: 1; flex-basis: auto; @@ -673,10 +670,6 @@ $tabs-holder-z-index: 250; } .mr-widget-body { - &:not(.mr-widget-body-line-height-1) { - line-height: 24px; - } - @include clearfix; .approve-btn { @@ -1003,7 +996,7 @@ $tabs-holder-z-index: 250; max-width: 650px; max-height: calc(100vh - 50px); - .gl-new-dropdown-inner { + .gl-dropdown-inner { max-height: none !important; } @@ -1038,7 +1031,7 @@ $tabs-holder-z-index: 250; } } - .gl-new-dropdown-contents { + .gl-dropdown-contents { padding: $gl-spacing-scale-4 !important; } @@ -1048,20 +1041,28 @@ $tabs-holder-z-index: 250; } .mr-widget-merge-details { + *, + & { + @include gl-font-sm; + } + + p { + @include gl-font-base; + } + li:not(:last-child) { - @include gl-mb-3; + @include gl-mb-2; } } -.mr-ready-merge-related-links, -.mr-widget-merge-details { - a { - @include gl-text-decoration-underline; +.mr-ready-merge-related-links a, +.mr-widget-merge-details a, +.mr-widget-author { + @include gl-text-decoration-underline; - &:hover, - &:focus { - @include gl-text-decoration-none; - } + &:hover, + &:focus { + @include gl-text-decoration-none; } } @@ -1075,36 +1076,20 @@ $tabs-holder-z-index: 250; } } -.detail-page-header-actions { - .gl-toggle { - @include gl-ml-auto; - @include gl-rounded-pill; - @include gl-w-9; - - &.is-checked:hover { - background-color: $blue-500; - } - } -} - .page-with-icon-sidebar .issue-sticky-header { --width: calc(100% - #{$contextual-sidebar-collapsed-width}); } .merge-request-notification-toggle { + .gl-toggle { + @include gl-ml-auto; + } + .gl-toggle-label { @include gl-font-weight-normal; } } -.dropdown-menu li button.gl-toggle:not(.is-checked) { - background: $gray-400; -} - -.mr-widget-content-row:first-child { - border-top: 0; -} - .mr-widget-status-icon-level-1::before { @include gl-content-empty; @include gl-absolute; diff --git a/app/assets/stylesheets/page_bundles/milestone.scss b/app/assets/stylesheets/page_bundles/milestone.scss index 63bcb83e747..9ee6d17cb50 100644 --- a/app/assets/stylesheets/page_bundles/milestone.scss +++ b/app/assets/stylesheets/page_bundles/milestone.scss @@ -1,17 +1,7 @@ @import 'page_bundles/mixins_and_variables_and_functions'; -$status-box-line-height: 26px; - -.issues-sortable-list .str-truncated { - max-width: 90%; -} - .milestones { .milestone { - h4 { - font-weight: $gl-font-weight-bold; - } - .progress { width: 100%; height: 6px; diff --git a/app/assets/stylesheets/page_bundles/oncall_schedules.scss b/app/assets/stylesheets/page_bundles/oncall_schedules.scss index 91fd2d42657..b995724ec7c 100644 --- a/app/assets/stylesheets/page_bundles/oncall_schedules.scss +++ b/app/assets/stylesheets/page_bundles/oncall_schedules.scss @@ -9,7 +9,7 @@ @include gl-w-full; } - .gl-new-dropdown-item-text-primary { + .gl-dropdown-item-text-primary { @include gl-overflow-hidden; @include gl-text-overflow-ellipsis; } diff --git a/app/assets/stylesheets/page_bundles/pipelines.scss b/app/assets/stylesheets/page_bundles/pipelines.scss index 4946bbbebe5..f9c49b0e6ca 100644 --- a/app/assets/stylesheets/page_bundles/pipelines.scss +++ b/app/assets/stylesheets/page_bundles/pipelines.scss @@ -70,33 +70,20 @@ } } -// Mini Pipelines - -.stage-cell { - .stage-container { - &:last-child { - margin-right: 0; - } - - // Hack to show a button tooltip inline - button.has-tooltip + .tooltip { - min-width: 105px; - } - - // Bootstrap way of showing the content inline for anchors. - a.has-tooltip { - white-space: nowrap; - } +// Pipeline mini graph +.pipeline-mini-graph-stage-container { + &:last-child { + margin-right: 0; + } - &:not(:last-child) { - &::after { - content: ''; - border-bottom: 2px solid $gray-200; - position: absolute; - right: -4px; - top: 11px; - width: 4px; - } + &:not(:last-child) { + &::after { + content: ''; + border-bottom: 2px solid $gray-200; + position: absolute; + right: -4px; + top: 11px; + width: 4px; } } } diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/page_bundles/search.scss index 1bca04e5eb1..10da541ed8d 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/page_bundles/search.scss @@ -1,17 +1,16 @@ +@import 'mixins_and_variables_and_functions'; + $search-dropdown-max-height: 400px; $search-avatar-size: 16px; $search-sidebar-min-width: 240px; $search-sidebar-max-width: 300px; -$search-input-field-x-min-width: 200px; -$search-input-field-min-width: 320px; -$search-input-field-max-width: 640px; $search-keyboard-shortcut: '/'; $border-radius-medium: 3px; .search-results { .search-result-row { - border-bottom: 1px solid $border-color; + border-bottom: 1px solid var(--border-color, $border-color); padding-bottom: $gl-padding; margin-bottom: $gl-padding; @@ -28,74 +27,6 @@ $border-radius-medium: 3px; } } -.search form:hover, -.file-finder-input:hover, -.issuable-search-form:hover, -.search-text-input:hover, -.form-control:hover, -:not[readonly] { - border-color: lighten($blue-300, 20%); - box-shadow: 0 0 4px lighten($dropdown-input-focus-shadow, 20%); -} - -input[type='checkbox']:hover { - box-shadow: 0 0 2px 2px lighten($dropdown-input-focus-shadow, 20%), - 0 0 0 1px lighten($dropdown-input-focus-shadow, 20%); -} - -.header-content { - .header-search-new { - max-width: $search-input-field-max-width; - } - - &.header-search-is-active { - .global-search-container { - flex-grow: 1; - } - } -} - -.header-search { - min-width: $search-input-field-min-width; - - // This is a temporary workaround! - // the button in GitLab UI Search components need to be updated to not be the small size - // see in Figma: https://www.figma.com/file/qEddyqCrI7kPSBjGmwkZzQ/Component-library?node-id=43905%3A45540 - .gl-search-box-by-type-clear.btn-sm { - padding: 0.5rem !important; - } - - @include media-breakpoint-between(md, lg) { - min-width: $search-input-field-x-min-width; - } - - &.is-searching { - .in-search-scope-help { - position: absolute; - top: $gl-spacing-scale-2; - right: 2.125rem; - z-index: 2; - } - } - - &.is-not-focused { - .gl-search-box-by-type-clear { - display: none; - } - } - - .keyboard-shortcut-helper { - transform: translateY(calc(50% - 2px)); - box-shadow: none; - border-color: transparent; - } -} - -.header-search-dropdown-menu { - max-height: $dropdown-max-height; - top: 100%; -} - .search { margin: 0 8px; @@ -171,7 +102,7 @@ input[type='checkbox']:hover { .dropdown-header { // Necessary because deprecatedJQueryDropdown doesn't support a second style of headers font-weight: $gl-font-weight-bold; - color: $gl-text-color; + color: var(--gl-text-color, $gl-text-color); font-size: $gl-font-size; line-height: 16px; } @@ -194,24 +125,24 @@ input[type='checkbox']:hover { &.search-active { form { - border-color: $blue-300; + border-color: var(--blue-300, $blue-300); box-shadow: none; .search-input-wrap { .search-icon, .clear-icon { - color: $gl-text-color-tertiary; + color: var(--gray-400, $gl-text-color-tertiary); transition: color ease-in-out $default-transition-duration; } } .search-input { - color: $gl-text-color; + color: var(--gl-text-color, $gl-text-color); transition: color ease-in-out $default-transition-duration; } .search-input::placeholder { - color: $gl-text-color-tertiary; + color: var(--gray-400, $gl-text-color-tertiary); } } } @@ -230,7 +161,7 @@ input[type='checkbox']:hover { .inline-search-icon { position: relative; margin-right: 4px; - color: $gl-text-color-secondary; + color: var(--gray-500, $gl-text-color-secondary); } .identicon, @@ -244,7 +175,7 @@ input[type='checkbox']:hover { width: $search-avatar-size; height: $search-avatar-size; border-radius: 50%; - border: 1px solid $gray-normal; + border: 1px solid var(--gray-50, $gray-normal); } } @@ -265,7 +196,7 @@ input[type='checkbox']:hover { position: absolute; left: 10px; top: 9px; - color: $gray-darkest; + color: var(--gray-700, $gray-darkest); pointer-events: none; } @@ -283,7 +214,7 @@ input[type='checkbox']:hover { .btn-search, .btn-success, .dropdown-menu-toggle, - .gl-new-dropdown { + .gl-dropdown { width: 100%; margin-top: 5px; @@ -302,7 +233,7 @@ input[type='checkbox']:hover { } .dropdown-menu-toggle, - .gl-new-dropdown { + .gl-dropdown { @include media-breakpoint-up(sm) { width: 180px; margin-top: 0; @@ -317,7 +248,7 @@ input[type='checkbox']:hover { } .dropdown-menu-toggle, - .gl-new-dropdown { + .gl-dropdown { @include media-breakpoint-up(lg) { width: 240px; } diff --git a/app/assets/stylesheets/page_bundles/settings.scss b/app/assets/stylesheets/page_bundles/settings.scss new file mode 100644 index 00000000000..9037eb7ae62 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/settings.scss @@ -0,0 +1,209 @@ +@import 'mixins_and_variables_and_functions'; + +@keyframes expandMaxHeight { + 0% { + max-height: 0; + } + + 99% { + max-height: 100vh; + } + + 100% { + max-height: none; + } +} + +@keyframes collapseMaxHeight { + 0% { + max-height: 100vh; + } + + 100% { + max-height: 0; + } +} + +.settings { + // border-top for each item except the top one + border-top: 1px solid var(--border-color, $border-color); + + &:first-of-type { + margin-top: 10px; + padding-top: 0; + border: 0; + } + + + div .settings:first-of-type { + margin-top: 0; + border-top: 1px solid var(--border-color, $border-color); + } + + &.animating { + overflow: hidden; + } +} + +.settings-header { + position: relative; + padding: $gl-padding-24 110px 0 0; + + h4 { + margin-top: 0; + } + + .settings-title { + cursor: pointer; + } + + button { + position: absolute; + top: 20px; + right: 6px; + min-width: 80px; + } +} + +.settings-content { + max-height: 1px; + overflow-y: hidden; + padding-right: 110px; + animation: collapseMaxHeight 300ms ease-out; + // Keep the section from expanding when we scroll over it + pointer-events: none; + + .settings.expanded & { + max-height: none; + overflow-y: visible; + animation: expandMaxHeight 300ms ease-in; + // Reset and allow clicks again when expanded + pointer-events: auto; + } + + .settings.no-animate & { + animation: none; + } + + @media(max-width: map-get($grid-breakpoints, md)-1) { + padding-right: 20px; + } + + &::before { + content: ' '; + display: block; + height: 1px; + overflow: hidden; + margin-bottom: 4px; + } + + &::after { + content: ' '; + display: block; + height: 1px; + overflow: hidden; + margin-top: 20px; + } + + .sub-section { + margin-bottom: 32px; + padding: 16px; + border: 1px solid var(--border-color, $border-color); + background-color: var(--gray-light, $gray-light); + } + + .bs-callout, + .form-check:first-child, + .form-check .form-text.text-muted, + .form-check + .form-text.text-muted { + margin-top: 0; + } + + .form-check .form-text.text-muted { + margin-bottom: $grid-size; + } +} + +.settings-list-icon { + color: var(--gray-500, $gl-text-color-secondary); + font-size: $default-icon-size; + line-height: 42px; +} + +.settings-message { + padding: 5px; + line-height: 1.3; + color: var(--gray-900, $gray-900); + background-color: var(--orange-50, $orange-50); + border: 1px solid var(--orange-200, $orange-200); + border-radius: $gl-border-radius-base; +} + +.prometheus-metrics-monitoring { + .card { + .card-toggle { + width: 14px; + } + + .badge.badge-pill { + font-size: 12px; + line-height: 12px; + } + + .card-header .label-count { + color: var(--white, $white); + background: var(--gray-800, $gray-800); + } + + .card-body { + padding: 0; + } + + .flash-container { + margin-bottom: 0; + cursor: default; + + .flash-notice { + border-radius: 0; + } + } + } + + .custom-monitored-metrics { + .card-header { + display: flex; + align-items: center; + } + + .custom-metric { + display: flex; + align-items: center; + } + + .custom-metric-link-bold { + font-weight: $gl-font-weight-bold; + text-decoration: none; + } + } + + .loading-metrics .metrics-load-spinner { + color: var(--gray-700, $gray-700); + } + + .metrics-list { + margin-bottom: 0; + + li { + padding: $gl-padding; + + .badge.badge-pill { + margin-left: 5px; + background: $badge-bg; + } + + /* Ensure we don't add border if there's only single li */ + + li { + border-top: 1px solid var(--border-color, $border-color); + } + } + } +} diff --git a/app/assets/stylesheets/page_bundles/todos.scss b/app/assets/stylesheets/page_bundles/todos.scss index 3eacf98688e..b35f5b38740 100644 --- a/app/assets/stylesheets/page_bundles/todos.scss +++ b/app/assets/stylesheets/page_bundles/todos.scss @@ -7,8 +7,16 @@ .todos-list > .todo { // workaround because we cannot use border-collapse + padding: 6px 12px !important; border-top: 1px solid transparent; + span:not(.todo-label), + button, + a:not(.todo-target-link), + time { + @include gl-relative; + } + // overwrite border style of .content-list &:last-child { border-bottom: 1px solid transparent; @@ -38,25 +46,66 @@ .todo-item { @include transition(opacity); - .todo-label, - .todo-project { - a { - color: var(--blue-600, $blue-600); - } + .todo-label a::before { + // Make area of the todo item clickable by expanding the area around the todo link + @include gl-content-empty; + @include gl-absolute; + @include gl-left-0; + @include gl-right-0; + @include gl-top-0; + @include gl-bottom-0; + z-index: 9; } +} - .todo-body { - p { - color: var(--gl-text-color, $gl-text-color); - } +.todo-title { + margin-right: 2.5rem; - .gl-label-scoped { - --label-inset-border: inset 0 0 0 1px currentColor; - } + @include media-breakpoint-up(sm) { + @include gl-mr-0; + @include gl-text-overflow-ellipsis; + @include gl-white-space-nowrap; + @include gl-overflow-hidden; + } +} - @include media-breakpoint-down(sm) { - border-left: 2px solid var(--border-color, $border-color); - padding-left: 10px; - } +.todo-body { + p { + @include gl-display-inline; + color: var(--gl-text-color, $gl-text-color); + } + + pre.code.highlight { + @include gl-py-0; + @include gl-px-1; + @include gl-m-0; + @include gl-bg-gray-50; + @include gl-border-0; + @include gl-rounded-base; + @include gl-display-inline-flex; + @include gl-text-body; + } + + .gl-label-scoped { + --label-inset-border: inset 0 0 0 1px currentColor; + } + + .avatar { + @include gl-mb-0; + } +} + +.todo-actions, +.todo-body .todo-avatar, +.todos-list > .todo a:not(.todo-target-link) { + z-index: 11 !important; +} + +.todo-actions { + @include gl-absolute; + @include gl-right-0; + + @include media-breakpoint-up(sm) { + @include gl-relative; } } diff --git a/app/assets/stylesheets/page_bundles/tree.scss b/app/assets/stylesheets/page_bundles/tree.scss index 58e55e11f7e..50d9684c7d2 100644 --- a/app/assets/stylesheets/page_bundles/tree.scss +++ b/app/assets/stylesheets/page_bundles/tree.scss @@ -205,3 +205,18 @@ .blob-content-holder { margin-top: $gl-padding; } + + +.web-ide-promo-popover { + box-shadow: 0 0 18px -1.9px rgba(119, 89, 194, 0.16), + 0 0 12.9px -1.7px rgba(119, 89, 194, 0.16), 0 0 9.2px -1.4px rgba(119, 89, 194, 0.16), + 0 0 6.4px -1.1px rgba(119, 89, 194, 0.16), 0 0 4.5px -0.8px rgba(119, 89, 194, 0.16), + 0 0 3px -0.6px rgba(119, 89, 194, 0.16), 0 0 1.8px -0.3px rgba(119, 89, 194, 0.16), + 0 0 0.6px rgba(119, 89, 194, 0.16); + z-index: 999; +} + +.web-ide-promo-popover-illustration { + width: calc(100% + 24px); + margin: -28px -12px 0; +} diff --git a/app/assets/stylesheets/pages/users.scss b/app/assets/stylesheets/page_bundles/users.scss index 3dcc17df61a..d4cd28504fc 100644 --- a/app/assets/stylesheets/pages/users.scss +++ b/app/assets/stylesheets/page_bundles/users.scss @@ -1,3 +1,5 @@ +@import 'mixins_and_variables_and_functions'; + .user-search-form { position: relative; diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss index 820a1a0b53e..4766f124e5b 100644 --- a/app/assets/stylesheets/page_bundles/work_items.scss +++ b/app/assets/stylesheets/page_bundles/work_items.scss @@ -81,4 +81,8 @@ } } } + + > .col { + min-width: 0; + } } diff --git a/app/assets/stylesheets/pages/colors.scss b/app/assets/stylesheets/pages/colors.scss index 20e072b9903..d1917948c88 100644 --- a/app/assets/stylesheets/pages/colors.scss +++ b/app/assets/stylesheets/pages/colors.scss @@ -22,3 +22,11 @@ display: none; } } + +.warning-title { + color: var(--gray-900, $gray-900); +} + +.danger-title { + color: var(--red-500, $red-500); +} diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 19318d87731..dd24e3fcb5d 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -338,11 +338,6 @@ color: $gl-text-color; } -.commit .gpg-popover-help-link { - display: block; - color: $link-color; -} - .add-review-item { .gl-tab-nav-item { height: 100%; diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index ce8dd6684f2..f6c79a4eca2 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -4,7 +4,7 @@ */ .event-item { font-size: $gl-font-size; - padding: $gl-padding 0 $gl-padding 56px; + padding: $gl-padding 0 $gl-padding $gl-spacing-scale-8; border-bottom: 1px solid $white-normal; color: $gl-text-color-secondary; position: relative; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss deleted file mode 100644 index 1b6e7954366..00000000000 --- a/app/assets/stylesheets/pages/issuable.scss +++ /dev/null @@ -1,912 +0,0 @@ -.status-box { - padding: 0 $gl-btn-padding; - border-radius: $border-radius-default; - display: block; - float: left; - margin-right: $gl-padding-8; - color: $white; - font-size: $gl-font-size; - line-height: $gl-line-height-24; -} - -.issuable-warning-icon { - background-color: $orange-50; - border-radius: $border-radius-default; - color: $orange-600; - width: $issuable-warning-size; - height: $issuable-warning-size; - text-align: center; - margin-right: $issuable-warning-icon-margin; - line-height: $gl-line-height-24; - flex: 0 0 auto; -} - -.limit-container-width { - .flash-container, - .detail-page-header, - .page-content-header, - .commit-box, - .info-well, - .commit-ci-menu, - .files-changed-inner, - .limited-header-width, - .limited-width-notes { - @include fixed-width-container; - } - - .issuable-details { - .detail-page-description, - .mr-source-target, - .mr-state-widget, - .merge-manually { - @include fixed-width-container; - } - } - - .merge-request-details { - .emoji-list-container { - @include fixed-width-container; - } - } -} - -.issuable-details { - section { - .issuable-discussion { - margin-right: 1px; - } - } - - .title-container { - display: flex; - align-items: flex-start; - } - - .title { - padding: 0; - margin-bottom: $gl-padding; - border-bottom: 0; - word-wrap: break-word; - overflow-wrap: break-word; - min-width: 0; - width: 100%; - text-align: initial; - } - - .btn-edit { - margin-left: auto; - } -} - -.issuable-show-labels { - .gl-label { - margin-bottom: 5px; - margin-right: 5px; - } - - a { - display: inline-block; - - .color-label { - padding: 4px $grid-size; - border-radius: $label-border-radius; - margin-right: 4px; - margin-bottom: 4px; - } - - &:hover .color-label { - text-decoration: underline; - } - } - - &.has-labels { - // this font size is a fix to - // prevent unintended spacing between labels - // which shows up when rendering markup has white-space - // characters present. - // see: https://css-tricks.com/fighting-the-space-between-inline-block-elements/#article-header-id-3 - font-size: 0; - margin-bottom: -5px; - } -} - -.assignee, -.reviewer { - .merge-icon { - color: $orange-400; - position: absolute; - bottom: -3px; - right: -3px; - filter: drop-shadow(0 0 0.5px $white) drop-shadow(0 0 1px $white) drop-shadow(0 0 2px $white); - } -} - -@mixin right-sidebar { - position: fixed; - top: $header-height; - // Default value for CSS var must contain a unit - // stylelint-disable-next-line length-zero-no-unit - bottom: var(--review-bar-height, 0px); - right: 0; - transition: width $gl-transition-duration-medium; - background-color: $white; - z-index: 200; - overflow: hidden; - -} - -.right-sidebar { - &:not(.right-sidebar-merge-requests) { - @include right-sidebar; - } - - &.right-sidebar-merge-requests { - @include media-breakpoint-down(md) { - @include right-sidebar; - z-index: 251; - } - } - - @include media-breakpoint-down(sm) { - z-index: 251; - } - - a:not(.btn) { - color: inherit; - - &:hover { - color: $blue-800; - } - } - - .gl-label .gl-label-link:hover { - color: inherit; - } - - .btn-link { - color: inherit; - } - - .issuable-header-text { - margin-top: 7px; - } - - .gutter-toggle { - display: flex; - align-items: center; - margin-left: 20px; - padding: 4px; - border-radius: 4px; - height: 24px; - - &:hover { - color: $gl-text-color; - background: $gray-50; - } - - &:hover, - &:focus { - text-decoration: none; - } - } - - &.right-sidebar-merge-requests { - .block, - .sidebar-contained-width, - .issuable-sidebar-header { - width: 100%; - } - - .block { - @include media-breakpoint-up(lg) { - padding: $gl-spacing-scale-4 0 $gl-spacing-scale-5; - } - - &.participants { - border-bottom: 0; - } - } - } - - .block, - .sidebar-contained-width, - .issuable-sidebar-header { - @include clearfix; - padding: $gl-spacing-scale-4 0 $gl-spacing-scale-5; - border-bottom: 1px solid $border-gray-normal; - // This prevents the mess when resizing the sidebar - // of elements repositioning themselves.. - width: $gutter-inner-width; - // -- - - &:last-child { - border: 0; - } - - &.assignee { - .author-link { - display: block; - position: relative; - - &:hover { - .author { - text-decoration: underline; - } - } - } - } - - &.time-tracking, - &.participants, - &.subscriptions, - &.with-sub-blocks { - padding-top: $gl-spacing-scale-5; - } - } - - .block-first { - padding-top: 0; - } - - .title { - color: $gl-text-color; - line-height: $gl-line-height-20; - - .avatar { - margin-left: 0; - } - } - - .selectbox { - display: none; - - &.show { - display: block; - } - } - - .btn-clipboard:hover { - color: $gl-text-color; - } - - .issuable-sidebar { - height: 100%; - - &:not(.is-merge-request) { - overflow-y: scroll; - overflow-x: hidden; - -webkit-overflow-scrolling: touch; - } - - &.is-merge-request { - @include media-breakpoint-down(sm) { - overflow-y: scroll; - overflow-x: hidden; - -webkit-overflow-scrolling: touch; - } - } - } - - &.right-sidebar-expanded { - &:not(.right-sidebar-merge-requests) { - width: $gutter-width; - } - - .value { - line-height: 1; - } - - .issuable-sidebar { - padding: 0 20px; - - &.is-merge-request { - @include media-breakpoint-up(lg) { - padding: 0; - - .issuable-context-form { - --initial-top: calc(#{$header-height} + 76px); - --top: var(--initial-top); - - @include gl-sticky; - @include gl-overflow-auto; - - top: var(--top); - height: calc(100vh - var(--top)); - padding: 0 15px; - margin-bottom: calc(var(--top) * -1); - - .with-performance-bar & { - --top: calc(var(--initial-top) + #{$performance-bar-height}); - } - - .with-system-header & { - --top: calc(var(--initial-top) + #{$system-header-height}); - } - - .with-performance-bar.with-system-header & { - --top: calc(var(--initial-top) + #{$system-header-height} + #{$performance-bar-height}); - } - } - } - } - } - - &:not(.boards-sidebar):not([data-signed-in]):not([data-always-show-toggle]) { - .issuable-sidebar-header { - display: none; - } - } - - .light { - font-weight: $gl-font-weight-normal; - } - - .no-value { - color: $gl-text-color-secondary; - } - - .sidebar-collapsed-icon { - display: none; - } - - .gutter-toggle { - text-align: center; - } - - .title .gutter-toggle { - margin-top: 0; - } - - .assignee .user-list .avatar { - margin: 0; - } - - .hide-expanded { - display: none; - } - } - - &.right-sidebar-collapsed { - /* Extra small devices (phones, less than 768px) */ - display: none; - /* Small devices (tablets, 768px and up) */ - - &:not(.right-sidebar-merge-requests) { - @include media-breakpoint-up(sm) { - display: block; - } - } - - &.right-sidebar-merge-requests { - @include media-breakpoint-up(lg) { - display: block; - } - } - - width: $gutter-collapsed-width; - padding: 0; - - .block, - .sidebar-contained-width, - .issuable-sidebar-header { - width: $gutter-collapsed-width - 2px; - padding: 0; - border-bottom: 0; - overflow: hidden; - } - - .block, - .gutter-toggle, - .sidebar-collapsed-container { - &.with-sub-blocks .sub-block:hover, - &:not(.with-sub-blocks):hover { - background-color: $gray-100; - } - } - - .participants { - border-bottom: 1px solid $border-gray-normal; - } - - .hide-collapsed { - display: none; - } - - .gutter-toggle { - width: 100%; - height: $sidebar-toggle-height; - margin-left: 0; - border-bottom: 1px solid $border-white-normal; - border-radius: 0; - } - - a.gutter-toggle { - display: flex; - justify-content: center; - flex-direction: column; - text-align: center; - } - - .merge-icon { - height: 12px; - width: 12px; - bottom: -5px; - right: 4px; - } - - .sidebar-collapsed-icon { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - width: 100%; - height: $sidebar-toggle-height; - text-align: center; - color: $gl-text-color-secondary; - - > svg { - fill: $gl-text-color-secondary; - } - - &:hover:not(.disabled), - &:hover .todo-undone { - color: $gl-text-color; - - > svg { - fill: $gl-text-color; - } - } - - .todo-undone { - color: $blue-600; - fill: $blue-600; - } - - .author { - display: none; - } - - .avatar-counter:hover { - color: $gl-text-color-secondary; - border-color: $gl-text-color-secondary; - } - - .btn-clipboard { - /* - This change should be temporary, because the DOM currently gets - generated from a ruby definition in `app/helpers/button_helper.rb`. - As soon as the `copy to clipboard` button will be transferred to - Vue this should be adjusted as well. - */ - flex: 1; - align-self: stretch; - padding: 0; - - border: 0; - background: transparent; - color: $gl-text-color-secondary; - - &:hover { - color: $gl-text-color; - } - } - - &.multiple-users { - display: flex; - justify-content: center; - } - } - - .sidebar-avatar-counter { - width: 24px; - height: 24px; - border-radius: 12px; - - ~.merge-icon { - bottom: 0; - } - } - - .sidebar-collapsed-user { - padding-bottom: 0; - - .author-link { - padding-left: 0; - - .avatar { - position: static; - margin: 0; - } - } - } - - .issuable-header-btn { - display: none; - } - - .multiple-users { - .btn-link { - padding: 0; - border: 0; - - .avatar { - margin: 0; - } - } - - .btn-link:first-child { - position: absolute; - left: 10px; - z-index: 1; - } - - .btn-link:last-child { - position: absolute; - right: 10px; - - &:hover { - text-decoration: none; - } - } - } - - .milestone-title span, - .collapse-truncated-title { - @include str-truncated(100%); - display: block; - margin: 0 4px; - } - } - - .dropdown-menu-toggle { - width: 100%; - padding-top: 6px; - } - - .dropdown-menu { - width: 100%; - - /* - * Overwrite hover style for dropdown items, so that they are not blue - * This should be removed during dev of https://gitlab.com/gitlab-org/gitlab-foss/issues/44040 - */ - li a { - &:hover, - &:active, - &:focus, - &.is-focused { - @include dropdown-item-hover; - } - } - - } -} - -.with-performance-bar .right-sidebar { - top: calc(#{$header-height} + #{$performance-bar-height}); -} - -.sidebar-move-issue-confirmation-button { - width: 100%; - - &.is-loading { - .sidebar-move-issue-confirmation-loading-icon { - display: inline-block; - } - } -} - -.sidebar-move-issue-confirmation-loading-icon { - display: none; -} - -.detail-page-description { - padding: 16px 0; - - small { - color: $gray-500; - } -} - -.edited-text { - color: $gray-500; - display: block; - margin: 16px 0 0; - font-size: 85%; - - .author-link { - color: $gray-500; - } -} - -.participants-author { - &:nth-of-type(8n) { - padding-right: 0; - } - - .avatar.avatar-inline { - margin: 0; - } -} - -.user-item { - padding: 5px; - flex-basis: 20%; - - .user-link { - display: inline-block; - } -} - -.participants-more, -.user-list-more { - margin-left: 5px; - - a, - .btn-link { - color: $gl-text-color-secondary; - } - - .btn-link { - padding: 0; - } - - .btn-link:hover { - color: $blue-800; - text-decoration: none; - } - - .btn-link:focus { - text-decoration: none; - } -} - -.issuable-gutter-toggle { - @include media-breakpoint-down(sm) { - margin-left: $btn-side-margin; - } -} - -.issuable-meta { - flex: 1; - display: inline-block; - font-size: 14px; - align-self: center; - overflow: hidden; - text-overflow: ellipsis; - - .user-status-emoji { - margin-left: $gl-padding-4; - margin-right: 0; - } -} - -.js-issuable-selector-wrap { - .js-issuable-selector { - width: 100%; - } - - @include media-breakpoint-down(sm) { - margin-bottom: $gl-padding; - } -} - -.issuable-list { - li { - .issuable-info-container { - flex: 1; - display: flex; - } - - .issuable-main-info { - flex: 1 auto; - margin-right: 10px; - min-width: 0; - - .issue-weight-icon, - .issue-estimate-icon { - vertical-align: sub; - } - } - - .issuable-meta { - display: flex; - flex-direction: column; - align-items: flex-end; - flex: 1 0 auto; - - .controls { - margin-bottom: 2px; - line-height: 20px; - padding: 0; - } - } - - @include media-breakpoint-down(xs) { - .issuable-meta { - .controls li { - margin-right: 0; - } - } - } - - .issue-check { - min-width: 15px; - } - - .issuable-milestone, - .issuable-info, - .task-status, - .issuable-timestamp { - font-weight: $gl-font-weight-normal; - color: $gl-text-color-secondary; - - a { - color: $gl-text-color; - } - - .gl-label-link { - color: inherit; - - &:hover { - text-decoration: none; - - .gl-label-text:last-of-type { - text-decoration: underline; - } - } - } - - .milestone { - color: $gray-700; - } - } - - @media(max-width: map-get($grid-breakpoints, lg)-1) { - .task-status, - .issuable-due-date, - .issuable-weight, - .project-ref-path { - display: none; - } - } - } -} - -.issuable-list li, -.issuable-info-container .controls { - .avatar-counter { - display: inline-block; - vertical-align: middle; - min-width: 16px; - line-height: 14px; - height: 16px; - padding-left: 2px; - padding-right: 2px; - } -} - -.add-issuable-form-input-wrapper { - &.focus { - border-color: $gray-700; - @include gl-focus; - - input { - @include gl-shadow-none; - } - } - - .gl-show-field-errors &.form-control:not(textarea) { - height: auto; - } -} - -.sidebar-help-wrap { - .sidebar-help-state { - margin: 16px -20px -20px; - padding: 16px 20px; - } - - .help-state-toggle-enter-active { - transition: all 0.8s ease; - } - - .help-state-toggle-leave-active { - transition: all 0.5s ease; - } - - .help-state-toggle-enter, - .help-state-toggle-leave-active { - opacity: 0; - } -} - -.time-tracker { - .sidebar-collapsed-icon { - > .stopwatch-svg { - display: inline-block; - } - - svg { - width: 16px; - height: 16px; - fill: $gl-text-color-secondary; - } - - &:hover svg { - fill: $gl-text-color; - } - } - - .compare-meter { - &.over_estimate { - .time-remaining, - .compare-value.spent { - color: $red-500; - } - } - } - - .compare-display-container { - font-size: 13px; - } -} - -/* - * Following overrides are done to prevent - * legacy dropdown styles from influencing - * GitLab UI components used within GlDropdown - */ -.issuable-move-dropdown { - .b-dropdown-form { - @include gl-p-0; - } - - .gl-search-box-by-type button.gl-clear-icon-button:hover { - @include gl-bg-transparent; - } - - .issuable-move-button:not(.disabled):hover { - @include gl-text-white; - } -} - -.right-sidebar-collapsed { - .sidebar-grouped-item { - .sidebar-collapsed-icon { - margin-bottom: 0; - } - - .sidebar-collapsed-divider { - line-height: 5px; - font-size: 12px; - color: $gray-500; - - + .sidebar-collapsed-icon { - padding-top: 0; - } - } - } -} - -.suggestion-footer { - font-size: 12px; - line-height: 15px; - - .avatar { - margin-top: -3px; - border: 0; - } -} - -@include media-breakpoint-down(sm) { - // Overriding the following rule with the negative margin - // https://gitlab.com/gitlab-org/gitlab/-/blob/146c43c931c3743a140529307aea616e4aa9ff21/app/assets/stylesheets/framework/sidebar.scss#L1-5 - .container-fluid { - .issuable-list, - .issues-filters, - .epics-filters { - margin: 0 (-$gl-padding); - } - } -} diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss index d4ad6da7f4d..360ea20733d 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/pages/login.scss @@ -262,7 +262,7 @@ .footer-container, hr.footer-fixed { - position: absolute; + position: fixed; bottom: 0; left: 0; right: 0; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index b016d0a1068..6b662359a67 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -382,3 +382,9 @@ $comparison-empty-state-height: 62px; .survey-slide-up-enter-active { @include gl-transition-slow; } + +.mr-compare-dropdown { + .gl-button-text { + @include gl-w-full; + } +} diff --git a/app/assets/stylesheets/pages/ml_experiment_tracking.scss b/app/assets/stylesheets/pages/ml_experiment_tracking.scss index 2dff51cff92..c1582f2090b 100644 --- a/app/assets/stylesheets/pages/ml_experiment_tracking.scss +++ b/app/assets/stylesheets/pages/ml_experiment_tracking.scss @@ -14,3 +14,9 @@ color: $gl-text-color; } } + +table.candidate-details { + td { + padding: $gl-spacing-scale-3; + } +} diff --git a/app/assets/stylesheets/pages/monitor.scss b/app/assets/stylesheets/pages/monitor.scss deleted file mode 100644 index 25ff5abd774..00000000000 --- a/app/assets/stylesheets/pages/monitor.scss +++ /dev/null @@ -1,5 +0,0 @@ -.chart-tooltip > .popover { - min-width: 0; - width: max-content; - max-width: $chart-tooltip-max-width; -} diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index cb77c31d59a..adeab227670 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -287,8 +287,7 @@ table { .discussion-reply-holder { .reply-placeholder-text-field { - font-family: $monospace-font; - font-size: $gl-font-size-monospace; + @include gl-font-monospace; border-radius: $gl-border-radius-base; width: 100%; resize: none; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index fa3c87490f1..75d52424fd9 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -4,7 +4,7 @@ $system-note-svg-size: 1rem; @mixin vertical-line($left) { &::before { content: ''; - border-left: 2px solid var(--gray-10, $gray-10); + border-left: 2px solid var(--gray-50, $gray-50); position: absolute; top: $gl-padding-6; bottom: 0; @@ -60,6 +60,10 @@ $system-note-svg-size: 1rem; padding: $gl-padding-4 $gl-padding-8; } + &.draft-note .timeline-content:not(.flash-container) { + border: 0; + } + .note-header-info { min-height: 2rem; display: flex; @@ -81,7 +85,7 @@ $system-note-svg-size: 1rem; margin-top: 5px; } - .timeline-content { + .timeline-content:not(.flash-container) { margin-left: 2.5rem; border-left: 1px solid $border-color; border-right: 1px solid $border-color; @@ -93,16 +97,26 @@ $system-note-svg-size: 1rem; } } + &.draft-note .timeline-content:not(.flash-container) { + margin-left: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; + } + &:not(:first-of-type) .timeline-entry-inner { margin-left: 2.5rem; border-left: 1px solid $border-color; border-right: 1px solid $border-color; background-color: $white; - .timeline-content { + .timeline-content:not(.flash-container) { padding: $gl-padding-8 $gl-padding-8 $gl-padding-8 $gl-padding; } + .timeline-discussion-body-footer { + padding: 0 $gl-padding-8 0; + } + .timeline-avatar { margin: $gl-padding-8 0 0 $gl-padding; } @@ -111,6 +125,12 @@ $system-note-svg-size: 1rem; margin-left: 2rem; } } + + &:last-of-type .timeline-entry-inner { + border-bottom: 1px solid $border-color; + border-bottom-left-radius: $gl-border-radius-base; + border-bottom-right-radius: $gl-border-radius-base; + } } .diff-content { @@ -416,17 +436,17 @@ $system-note-svg-size: 1rem; .timeline-icon { display: flex; align-items: center; - background-color: $gray-10; + background-color: $gray-50; width: $system-note-icon-size; height: $system-note-icon-size; - border: 1px solid $gray-10; + border: 1px solid $gray-50; border-radius: $system-note-icon-size; margin: -6px 0 0; svg { width: $system-note-svg-size; height: $system-note-svg-size; - fill: $gray-400; + fill: $gray-600; display: block; margin: 0 auto; } @@ -1050,7 +1070,7 @@ $system-note-svg-size: 1rem; padding-left: 0; ul.notes li.note-wrapper { - .timeline-content { + .timeline-content:not(.flash-container) { padding: $gl-padding-8 $gl-padding-8 $gl-padding-8 $gl-padding; } @@ -1066,7 +1086,7 @@ $system-note-svg-size: 1rem; border-right: 0; } - div.discussion-reply-holder { + .discussion-reply-holder { margin-left: 0; } } @@ -1097,7 +1117,7 @@ $system-note-svg-size: 1rem; } } - .draft-note-component .draft-note.timeline-entry { + .draft-note-component.draft-note.timeline-entry { .timeline-content:not(.flash-container) { padding: $gl-padding-8 $gl-padding-8 $gl-padding-8 $gl-padding; } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index bf20204cfd9..15a32ea8ad3 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -247,7 +247,7 @@ .repository-languages-bar { height: 8px; - margin-bottom: $gl-padding-8; + margin-bottom: $gl-padding; background-color: $white; border-radius: $border-radius-default; @@ -562,7 +562,7 @@ // Remove once gitlab/ui solution is implemented // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1158 // https://gitlab.com/gitlab-org/gitlab/-/issues/300405 - .gl-new-dropdown-button-text { + .gl-dropdown-button-text { @include str-truncated; } } @@ -654,86 +654,3 @@ } } } - -.project-filters { - .btn svg { - color: $gray-700; - } - - .button-filter-group { - .btn { - width: 96px; - } - - a { - color: $black; - } - - .active { - background: $btn-active-gray; - } - } - - .filtered-search-dropdown-label { - min-width: 68px; - - @include media-breakpoint-down(xs) { - min-width: 60px; - } - } - - .filtered-search { - min-width: 30%; - flex-basis: 0; - - .project-filter-form .project-filter-form-field { - padding-right: $gl-padding-8; - } - - .filtered-search, - .filtered-search-nav, - .filtered-search-dropdown { - flex-basis: 0; - } - - @include media-breakpoint-down(lg) { - min-width: 15%; - - .project-filter-form-field { - min-width: 150px; - } - } - - @include media-breakpoint-down(md) { - min-width: 30%; - } - } - - .filtered-search-box { - border-radius: 3px 0 0 3px; - } - - .dropdown-menu-toggle { - margin-left: $gl-padding-8; - } - - @include media-breakpoint-down(md) { - .extended-filtered-search-box { - min-width: 55%; - } - - .filtered-search-dropdown { - width: 50%; - - .dropdown-menu-toggle { - width: 100%; - } - } - } - - @include media-breakpoint-down(xs) { - .filtered-search-dropdown { - width: 100%; - } - } -} diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index c364b233803..2d78ab82b7d 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -1,149 +1,3 @@ -@keyframes expandMaxHeight { - 0% { - max-height: 0; - } - - 99% { - max-height: 100vh; - } - - 100% { - max-height: none; - } -} - -@keyframes collapseMaxHeight { - 0% { - max-height: 100vh; - } - - 100% { - max-height: 0; - } -} - -.settings { - // border-top for each item except the top one - border-top: 1px solid $border-color; - - &:first-of-type { - margin-top: 10px; - padding-top: 0; - border: 0; - } - - + div .settings:first-of-type { - margin-top: 0; - border-top: 1px solid $border-color; - } - - &.animating { - overflow: hidden; - } -} - -.settings-header { - position: relative; - padding: 24px 110px 0 0; - - h4 { - margin-top: 0; - } - - .settings-title { - cursor: pointer; - } - - button { - position: absolute; - top: 20px; - right: 6px; - min-width: 80px; - } -} - -.settings-content { - max-height: 1px; - overflow-y: hidden; - padding-right: 110px; - animation: collapseMaxHeight 300ms ease-out; - // Keep the section from expanding when we scroll over it - pointer-events: none; - - .settings.expanded & { - max-height: none; - overflow-y: visible; - animation: expandMaxHeight 300ms ease-in; - // Reset and allow clicks again when expanded - pointer-events: auto; - } - - .settings.no-animate & { - animation: none; - } - - @media(max-width: map-get($grid-breakpoints, md)-1) { - padding-right: 20px; - } - - &::before { - content: ' '; - display: block; - height: 1px; - overflow: hidden; - margin-bottom: 4px; - } - - &::after { - content: ' '; - display: block; - height: 1px; - overflow: hidden; - margin-top: 20px; - } - - .sub-section { - margin-bottom: 32px; - padding: 16px; - border: 1px solid $border-color; - background-color: $gray-light; - } - - .bs-callout, - .form-check:first-child, - .form-check .form-text.text-muted, - .form-check + .form-text.text-muted { - margin-top: 0; - } - - .form-check .form-text.text-muted { - margin-bottom: $grid-size; - } -} - -.settings-list-icon { - color: $gl-text-color-secondary; - font-size: $default-icon-size; - line-height: 42px; -} - -.settings-message { - padding: 5px; - line-height: 1.3; - color: $gray-900; - background-color: $orange-50; - border: 1px solid $orange-200; - border-radius: $border-radius-base; -} - -.warning-title { - color: $gray-900; -} - -.danger-title { - color: $red-500; -} - .integration-settings-form { .card.card-body, .info-well { @@ -160,13 +14,13 @@ .option-title { font-weight: $gl-font-weight-normal; display: inline-block; - color: $gl-text-color; + color: var(--gl-text-color, $gl-text-color); vertical-align: top; } .option-description, .option-disabled-reason { - color: $project-option-descr-color; + color: var(--gray-700, $gray-700); } .option-disabled-reason { @@ -188,79 +42,9 @@ } } -.prometheus-metrics-monitoring { - .card { - .card-toggle { - width: 14px; - } - - .badge.badge-pill { - font-size: 12px; - line-height: 12px; - } - - .card-header .label-count { - color: $white; - background: $common-gray-dark; - } - - .card-body { - padding: 0; - } - - .flash-container { - margin-bottom: 0; - cursor: default; - - .flash-notice { - border-radius: 0; - } - } - } - - .custom-monitored-metrics { - .card-header { - display: flex; - align-items: center; - } - - .custom-metric { - display: flex; - align-items: center; - } - - .custom-metric-link-bold { - font-weight: $gl-font-weight-bold; - text-decoration: none; - } - } - - .loading-metrics .metrics-load-spinner { - color: $gray-700; - } - - .metrics-list { - margin-bottom: 0; - - li { - padding: $gl-padding; - - .badge.badge-pill { - margin-left: 5px; - background: $badge-bg; - } - - /* Ensure we don't add border if there's only single li */ - + li { - border-top: 1px solid $border-color; - } - } - } -} - .saml-settings.info-well { .form-control[readonly] { - background: $white; + background: var(--white, $white); } } @@ -275,8 +59,8 @@ } .btn-clipboard { - background-color: $white; - border: 1px solid $gray-100; + background-color: var(--white, $white); + border: 1px solid var(--gray-100, $gray-100); } .deploy-token-help-block { @@ -294,7 +78,7 @@ .ci-secure-files-table { table { thead { - border-bottom: 1px solid $white-normal; + border-bottom: 1px solid var(--gray-50, $gray-50); } tr { diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index 11131cc1a4b..c7e55289b11 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -2,25 +2,6 @@ // Please see the feedback issue for more details and help: // https://gitlab.com/gitlab-org/gitlab/-/issues/331812 @charset "UTF-8"; -:root { - color-scheme: dark; -} -body.gl-dark { - --gray-10: #1f1e24; - --gray-50: #333238; - --gray-100: #434248; - --gray-200: #535158; - --gray-700: #bfbfc3; - --gray-900: #ececef; - --green-100: #0d532a; - --green-700: #91d4a8; - --gl-text-color: #ececef; - --border-color: #4f4f4f; - --black: #fff; -} -:root { - --white: #333; -} *, *::before, *::after { @@ -36,9 +17,10 @@ header { } body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont, + "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", + sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji"; font-size: 1rem; font-weight: 400; line-height: 1.5; @@ -66,8 +48,9 @@ a:not([href]):not([class]) { text-decoration: none; } kbd { - font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", - "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace; + font-family: var(--default-mono-font, "Menlo"), "DejaVu Sans Mono", + "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono", + "lucida console", monospace; font-size: 1em; } img { @@ -117,7 +100,7 @@ button::-moz-focus-inner, kbd { padding: 0.2rem 0.4rem; font-size: 90%; - color: #333; + color: #333238; background-color: #ececef; border-radius: 0.2rem; } @@ -142,7 +125,7 @@ kbd kbd { font-weight: 400; line-height: 1.5; color: #ececef; - background-color: #333; + background-color: #333238; background-clip: padding-box; border: 1px solid #737278; border-radius: 0.25rem; @@ -158,7 +141,7 @@ kbd kbd { opacity: 1; } .form-control:disabled { - background-color: #333238; + background-color: #24232a; opacity: 1; } .form-inline { @@ -215,7 +198,7 @@ kbd kbd { color: #ececef; text-align: left; list-style: none; - background-color: #333; + background-color: #333238; background-clip: padding-box; border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 0.25rem; @@ -410,7 +393,7 @@ a.gl-badge.badge-info:active { background-color: #0b5cad; } a.gl-badge.badge-info:active { - box-shadow: 0 0 0 1px #333, 0 0 0 3px #1f75cb; + box-shadow: 0 0 0 1px #333238, 0 0 0 3px #1f75cb; outline: none; } .gl-badge.badge-success { @@ -423,7 +406,7 @@ a.gl-badge.badge-success:active { background-color: #24663b; } a.gl-badge.badge-success:active { - box-shadow: 0 0 0 1px #333, 0 0 0 3px #1f75cb; + box-shadow: 0 0 0 1px #333238, 0 0 0 3px #1f75cb; outline: none; } .gl-badge.badge-warning { @@ -436,7 +419,7 @@ a.gl-badge.badge-warning:active { background-color: #8f4700; } a.gl-badge.badge-warning:active { - box-shadow: 0 0 0 1px #333, 0 0 0 3px #1f75cb; + box-shadow: 0 0 0 1px #333238, 0 0 0 3px #1f75cb; outline: none; } .gl-button .gl-badge { @@ -444,10 +427,11 @@ a.gl-badge.badge-warning:active { } .gl-form-input, .gl-form-input.form-control { - background-color: #333; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + background-color: #333238; + font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont, + "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", + sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji"; font-size: 0.875rem; line-height: 1rem; padding-top: 0.5rem; @@ -526,12 +510,21 @@ a.gl-badge.badge-warning:active { font-size: 0.875rem; border-radius: 0.25rem; } +.gl-button.gl-button .gl-button-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-top: 1px; + padding-bottom: 1px; + margin-top: -1px; + margin-bottom: -1px; +} .gl-button.gl-button.btn-default { - background-color: #333; + background-color: #333238; } .gl-button.gl-button.btn-default:active, .gl-button.gl-button.btn-default.active { - box-shadow: inset 0 0 0 1px #a4a3a8, 0 0 0 1px #333, 0 0 0 3px #1f75cb; + box-shadow: inset 0 0 0 1px #a4a3a8, 0 0 0 1px #333238, 0 0 0 3px #1f75cb; outline: none; background-color: #434248; } @@ -613,7 +606,7 @@ html { font-size: 0.875rem; font-weight: 400; padding: 6px 10px; - background-color: #333; + background-color: #333238; border-color: #434248; color: #ececef; color: #ececef; @@ -625,7 +618,7 @@ html { } .btn:active, .btn.active { - background-color: #444; + background-color: #434248; border-color: #4f4f4f; color: #ececef; } @@ -649,7 +642,7 @@ html { position: relative; } .dropdown-menu-toggle:active { - box-shadow: 0 0 0 1px #333, 0 0 0 3px #1f75cb; + box-shadow: 0 0 0 1px #333238, 0 0 0 3px #1f75cb; outline: none; } .search-input-container .dropdown-menu { @@ -657,7 +650,7 @@ html { } .dropdown-menu-toggle { padding: 6px 8px 6px 10px; - background-color: #333; + background-color: #333238; color: #ececef; font-size: 14px; text-align: left; @@ -689,7 +682,7 @@ html { font-size: 0.875rem; font-weight: 400; padding: 8px 0; - background-color: #333; + background-color: #333238; border: 1px solid #434248; border-radius: 0.25rem; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); @@ -704,7 +697,7 @@ html { list-style: none; } .dropdown-menu li > a, -.dropdown-menu li button { +.dropdown-menu li > button { background: transparent; border: 0; border-radius: 0; @@ -721,16 +714,16 @@ html { width: 100%; } .dropdown-menu li > a:active, -.dropdown-menu li button:active { - background-color: #4f4f4f; +.dropdown-menu li > button:active { + background-color: #4e4c53; color: #ececef; outline: 0; text-decoration: none; } .dropdown-menu li > a:active, -.dropdown-menu li button:active { - box-shadow: inset 0 0 0 2px #1f75cb, inset 0 0 0 3px #333, - inset 0 0 0 1px #333; +.dropdown-menu li > button:active { + box-shadow: inset 0 0 0 2px #1f75cb, inset 0 0 0 3px #333238, + inset 0 0 0 1px #333238; outline: none; } .dropdown-menu .divider { @@ -765,7 +758,7 @@ html { input { border-radius: 0.25rem; color: #ececef; - background-color: #333; + background-color: #333238; } .form-control { border-radius: 4px; @@ -777,10 +770,10 @@ input { kbd { display: inline-block; padding: 3px 5px; - font-size: 0.6875rem; + font-size: 0.75rem; line-height: 10px; color: var(--gray-700, #bfbfc3); - vertical-align: middle; + vertical-align: unset; background-color: var(--gray-10, #1f1e24); border-width: 1px; border-style: solid; @@ -840,6 +833,22 @@ kbd { .navbar-gitlab .header-content .navbar-collapse > ul.nav > li:not(.d-none) { margin: 0 2px; } +.navbar-gitlab .header-content .header-search-new { + max-width: 640px; +} +.navbar-gitlab .header-search { + min-width: 320px; +} +@media (min-width: 768px) and (max-width: 1199.98px) { + .navbar-gitlab .header-search { + min-width: 200px; + } +} +.navbar-gitlab .header-search .keyboard-shortcut-helper { + transform: translateY(calc(50% - 2px)); + box-shadow: none; + border-color: transparent; +} .navbar-gitlab .navbar-collapse { flex: 0 0 auto; border-top: 0; @@ -1010,7 +1019,7 @@ kbd { float: left; margin-right: 5px; border-radius: 50%; - border: 1px solid #333; + border: 1px solid #333238; } .notification-dot { background-color: #9e5400; @@ -1049,7 +1058,7 @@ kbd { } .context-header .avatar-container { flex: 0 0 32px; - background-color: #333; + background-color: #333238; } .context-header .sidebar-context-title { overflow: hidden; @@ -1142,7 +1151,7 @@ kbd { font-weight: 600; } .nav-sidebar li.active:not(.fly-out-top-item) > a:not(.has-sub-items) { - background-color: rgba(255, 255, 255, 0.08); + background-color: rgba(251, 250, 253, 0.08); } .nav-sidebar ul { padding-left: 0; @@ -1189,7 +1198,7 @@ kbd { margin-bottom: -0.25rem; margin-top: 0; position: relative; - color: #333; + color: #333238; background: var(--black, #fff); } .nav-sidebar @@ -1417,7 +1426,7 @@ kbd { .close-nav-button { height: 48px; padding: 0 16px; - background-color: #333238; + background-color: #24232a; border: 0; color: #89888d; display: flex; @@ -1523,87 +1532,6 @@ svg.s12 { svg.s16 { vertical-align: -3px; } -.header-content .header-search-new { - max-width: 640px; -} -.header-search { - min-width: 320px; -} -@media (min-width: 768px) and (max-width: 1199.98px) { - .header-search { - min-width: 200px; - } -} -.header-search .keyboard-shortcut-helper { - transform: translateY(calc(50% - 2px)); - box-shadow: none; - border-color: transparent; -} -.search { - margin: 0 8px; -} -.search form { - display: block; - margin: 0; - padding: 4px; - width: 200px; - line-height: 24px; - height: 32px; - border: 0; - border-radius: 4px; -} -@media (min-width: 1200px) { - .search form { - width: 320px; - } -} -.search .search-input { - border: 0; - font-size: 14px; - padding: 0 20px 0 0; - margin-left: 5px; - line-height: 25px; - width: 98%; - color: #333; - background: none; -} -.search .search-input-container { - display: flex; - position: relative; -} -.search .search-input-wrap { - width: 100%; -} -.search .search-input-wrap .search-icon, -.search .search-input-wrap .clear-icon { - position: absolute; - right: 5px; - top: 4px; -} -.search .search-input-wrap .search-icon { - user-select: none; -} -.search .search-input-wrap .clear-icon { - display: none; -} -.search .search-input-wrap .dropdown { - position: static; -} -.search .search-input-wrap .dropdown-menu { - left: -5px; - max-height: 400px; - overflow: auto; -} -@media (min-width: 1200px) { - .search .search-input-wrap .dropdown-menu { - width: 320px; - } -} -.search .identicon { - flex-basis: 16px; - flex-shrink: 0; - margin-right: 4px; -} .avatar, .avatar-container { float: left; @@ -1627,7 +1555,7 @@ svg.s16 { width: 40px; height: 40px; padding: 0; - background: #222; + background: #212027; overflow: hidden; box-shadow: inset 0 0 0 1px rgba(251, 250, 253, 0.1); } @@ -1705,97 +1633,25 @@ svg.s16 { } :root { color-scheme: dark; -} -body.gl-dark { --gray-10: #1f1e24; --gray-50: #333238; --gray-100: #434248; --gray-200: #535158; - --gray-300: #626168; - --gray-400: #737278; - --gray-500: #89888d; - --gray-600: #a4a3a8; --gray-700: #bfbfc3; - --gray-800: #dcdcde; --gray-900: #ececef; - --gray-950: #fbfafd; - --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: #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; - --indigo-200: #393982; - --indigo-300: #4b4ba3; - --indigo-400: #5b5bbd; - --indigo-500: #6666c4; - --indigo-600: #7c7ccc; - --indigo-700: #a6a6de; - --indigo-800: #d1d1f0; - --indigo-900: #ebebfa; - --indigo-950: #f7f7ff; - --purple-50: #232150; - --purple-100: #2f2a6b; - --purple-200: #453894; - --purple-300: #5943b6; - --purple-400: #694cc0; - --purple-500: #7b58cf; - --purple-600: #9475db; - --purple-700: #ac93e6; - --purple-800: #cbbbf2; - --purple-900: #e1d8f9; - --purple-950: #f4f0ff; - --dark-icon-color-purple-1: #524a68; - --dark-icon-color-purple-2: #715bae; - --dark-icon-color-purple-3: #9a79f7; - --dark-icon-color-orange-1: #665349; - --dark-icon-color-orange-2: #b37a5d; --gl-text-color: #ececef; - --border-color: #4f4f4f; - --white: #333; + --border-color: #434248; + --white: #333238; + --black: #fff; +} +body.gl-dark { + color-scheme: dark; + --gray-10: #1f1e24; + --border-color: #434248; + --white: #333238; --black: #fff; - --gray-light: #333238; - --svg-status-bg: #333; } .nav-sidebar, .toggle-sidebar-button, @@ -1830,7 +1686,7 @@ body.gl-dark .navbar-gitlab .navbar-sub-nav > li.active > button, body.gl-dark .navbar-gitlab .navbar-nav > li.active > a, body.gl-dark .navbar-gitlab .navbar-nav > li.active > button { color: #ececef; - background-color: #333; + background-color: #333238; } body.gl-dark .navbar-gitlab .navbar-sub-nav { color: #ececef; @@ -1862,10 +1718,10 @@ body.gl-dark } body.gl-dark .navbar-gitlab .nav > li.active > a { color: #ececef; - background-color: #333; + background-color: #333238; } body.gl-dark .navbar-gitlab .nav > li.active > a .notification-dot { - border-color: #333; + border-color: #333238; } body.gl-dark .navbar-gitlab @@ -1947,100 +1803,6 @@ body.gl-dark .navbar-gitlab .search form .search-input { color: var(--gl-text-color); } -:root { - color-scheme: dark; -} -body.gl-dark { - --gray-10: #1f1e24; - --gray-50: #333238; - --gray-100: #434248; - --gray-200: #535158; - --gray-300: #626168; - --gray-400: #737278; - --gray-500: #89888d; - --gray-600: #a4a3a8; - --gray-700: #bfbfc3; - --gray-800: #dcdcde; - --gray-900: #ececef; - --gray-950: #fbfafd; - --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: #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; - --indigo-200: #393982; - --indigo-300: #4b4ba3; - --indigo-400: #5b5bbd; - --indigo-500: #6666c4; - --indigo-600: #7c7ccc; - --indigo-700: #a6a6de; - --indigo-800: #d1d1f0; - --indigo-900: #ebebfa; - --indigo-950: #f7f7ff; - --purple-50: #232150; - --purple-100: #2f2a6b; - --purple-200: #453894; - --purple-300: #5943b6; - --purple-400: #694cc0; - --purple-500: #7b58cf; - --purple-600: #9475db; - --purple-700: #ac93e6; - --purple-800: #cbbbf2; - --purple-900: #e1d8f9; - --purple-950: #f4f0ff; - --dark-icon-color-purple-1: #524a68; - --dark-icon-color-purple-2: #715bae; - --dark-icon-color-purple-3: #9a79f7; - --dark-icon-color-orange-1: #665349; - --dark-icon-color-orange-2: #b37a5d; - --gl-text-color: #ececef; - --border-color: #4f4f4f; - --white: #333; - --black: #fff; - --gray-light: #333238; - --svg-status-bg: #333; -} .tab-width-8 { tab-size: 8; } diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss index 7fb373bb6f4..f24b6fb9e81 100644 --- a/app/assets/stylesheets/startup/startup-general.scss +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -17,9 +17,10 @@ header { } body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont, + "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", + sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji"; font-size: 1rem; font-weight: 400; line-height: 1.5; @@ -47,8 +48,9 @@ a:not([href]):not([class]) { text-decoration: none; } kbd { - font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", - "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace; + font-family: var(--default-mono-font, "Menlo"), "DejaVu Sans Mono", + "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono", + "lucida console", monospace; font-size: 1em; } img { @@ -426,9 +428,10 @@ a.gl-badge.badge-warning:active { .gl-form-input, .gl-form-input.form-control { background-color: #fff; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont, + "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", + sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji"; font-size: 0.875rem; line-height: 1rem; padding-top: 0.5rem; @@ -507,6 +510,15 @@ a.gl-badge.badge-warning:active { font-size: 0.875rem; border-radius: 0.25rem; } +.gl-button.gl-button .gl-button-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-top: 1px; + padding-bottom: 1px; + margin-top: -1px; + margin-bottom: -1px; +} .gl-button.gl-button.btn-default { background-color: #fff; } @@ -606,8 +618,8 @@ html { } .btn:active, .btn.active { - background-color: #eaeaea; - border-color: #e3e3e3; + background-color: #e6e6ea; + border-color: #dedee3; color: #333238; } .btn svg { @@ -685,7 +697,7 @@ html { list-style: none; } .dropdown-menu li > a, -.dropdown-menu li button { +.dropdown-menu li > button { background: transparent; border: 0; border-radius: 0; @@ -702,14 +714,14 @@ html { width: 100%; } .dropdown-menu li > a:active, -.dropdown-menu li button:active { +.dropdown-menu li > button:active { background-color: #ececef; color: #333238; outline: 0; text-decoration: none; } .dropdown-menu li > a:active, -.dropdown-menu li button:active { +.dropdown-menu li > button:active { box-shadow: inset 0 0 0 2px #428fdc, inset 0 0 0 3px #fff, inset 0 0 0 1px #fff; outline: none; @@ -758,10 +770,10 @@ input { kbd { display: inline-block; padding: 3px 5px; - font-size: 0.6875rem; + font-size: 0.75rem; line-height: 10px; color: var(--gray-700, #535158); - vertical-align: middle; + vertical-align: unset; background-color: var(--gray-10, #fbfafd); border-width: 1px; border-style: solid; @@ -821,6 +833,22 @@ kbd { .navbar-gitlab .header-content .navbar-collapse > ul.nav > li:not(.d-none) { margin: 0 2px; } +.navbar-gitlab .header-content .header-search-new { + max-width: 640px; +} +.navbar-gitlab .header-search { + min-width: 320px; +} +@media (min-width: 768px) and (max-width: 1199.98px) { + .navbar-gitlab .header-search { + min-width: 200px; + } +} +.navbar-gitlab .header-search .keyboard-shortcut-helper { + transform: translateY(calc(50% - 2px)); + box-shadow: none; + border-color: transparent; +} .navbar-gitlab .navbar-collapse { flex: 0 0 auto; border-top: 0; @@ -1123,7 +1151,7 @@ kbd { font-weight: 600; } .nav-sidebar li.active:not(.fly-out-top-item) > a:not(.has-sub-items) { - background-color: rgba(0, 0, 0, 0.08); + background-color: rgba(31, 30, 36, 0.08); } .nav-sidebar ul { padding-left: 0; @@ -1504,87 +1532,6 @@ svg.s12 { svg.s16 { vertical-align: -3px; } -.header-content .header-search-new { - max-width: 640px; -} -.header-search { - min-width: 320px; -} -@media (min-width: 768px) and (max-width: 1199.98px) { - .header-search { - min-width: 200px; - } -} -.header-search .keyboard-shortcut-helper { - transform: translateY(calc(50% - 2px)); - box-shadow: none; - border-color: transparent; -} -.search { - margin: 0 8px; -} -.search form { - display: block; - margin: 0; - padding: 4px; - width: 200px; - line-height: 24px; - height: 32px; - border: 0; - border-radius: 4px; -} -@media (min-width: 1200px) { - .search form { - width: 320px; - } -} -.search .search-input { - border: 0; - font-size: 14px; - padding: 0 20px 0 0; - margin-left: 5px; - line-height: 25px; - width: 98%; - color: #fff; - background: none; -} -.search .search-input-container { - display: flex; - position: relative; -} -.search .search-input-wrap { - width: 100%; -} -.search .search-input-wrap .search-icon, -.search .search-input-wrap .clear-icon { - position: absolute; - right: 5px; - top: 4px; -} -.search .search-input-wrap .search-icon { - user-select: none; -} -.search .search-input-wrap .clear-icon { - display: none; -} -.search .search-input-wrap .dropdown { - position: static; -} -.search .search-input-wrap .dropdown-menu { - left: -5px; - max-height: 400px; - overflow: auto; -} -@media (min-width: 1200px) { - .search .search-input-wrap .dropdown-menu { - width: 320px; - } -} -.search .identicon { - flex-basis: 16px; - flex-shrink: 0; - margin-right: 4px; -} .avatar, .avatar-container { float: left; diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss index 7ae158b3930..d8afff1a200 100644 --- a/app/assets/stylesheets/startup/startup-signin.scss +++ b/app/assets/stylesheets/startup/startup-signin.scss @@ -16,9 +16,10 @@ header { } body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont, + "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", + sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji"; font-size: 1rem; font-weight: 400; line-height: 1.5; @@ -390,9 +391,10 @@ input.btn-block[type="button"] { .gl-form-input, .gl-form-input.form-control { background-color: #fff; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont, + "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", + sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji"; font-size: 0.875rem; line-height: 1rem; padding-top: 0.5rem; @@ -647,8 +649,8 @@ body.navless { } .btn:active, .btn.active { - background-color: #eaeaea; - border-color: #e3e3e3; + background-color: #e6e6ea; + border-color: #dedee3; color: #333238; } .btn svg { @@ -954,7 +956,7 @@ svg { } .devise-layout-html body .footer-container, .devise-layout-html body hr.footer-fixed { - position: absolute; + position: fixed; bottom: 0; left: 0; right: 0; @@ -988,6 +990,9 @@ svg { .gl-justify-content-center { justify-content: center; } +.gl-justify-content-space-between { + justify-content: space-between; +} .gl-float-right { float: right; } diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss index a3474d2ed50..8db91fd9908 100644 --- a/app/assets/stylesheets/themes/_dark.scss +++ b/app/assets/stylesheets/themes/_dark.scss @@ -11,6 +11,20 @@ $gray-800: #dcdcde; $gray-900: #ececef; $gray-950: #fbfafd; +$gray-lightest: lighten($gray-10, 1); +$gray-light: lighten($gray-10, 2); +$gray-lighter: darken($gray-50, 4); +$gray-normal: $gray-50; +$gray-dark: darken($gray-100, 2); +$gray-darker: darken($gray-200, 2); +$gray-darkest: $gray-700; + +$black: #fff; +$black-normal: $gray-900; +$white: $gray-50; +$white-normal: $gray-50; +$white-dark: $gray-100; + $green-50: #0a4020; $green-100: #0d532a; $green-200: #24663b; @@ -83,159 +97,21 @@ $purple-800: #cbbbf2; $purple-900: #e1d8f9; $purple-950: #f4f0ff; -$gray-lightest: #222; -$gray-light: $gray-50; -$gray-lighter: #303030; -$gray-normal: #333; -$gray-dark: $gray-100; -$gray-darker: #4f4f4f; -$gray-darkest: #c4c4c4; - -$black: #fff; -$black-normal: $gray-900; -$white: #333; -$white-light: #2b2b2b; -$white-normal: #333; -$white-dark: #444; - $theme-indigo-50: #1a1a40; $border-color: #4f4f4f; -:root { - color-scheme: dark; -} - -body.gl-dark { - --gray-10: #{$gray-10}; - --gray-50: #{$gray-50}; - --gray-100: #{$gray-100}; - --gray-200: #{$gray-200}; - --gray-300: #{$gray-300}; - --gray-400: #{$gray-400}; - --gray-500: #{$gray-500}; - --gray-600: #{$gray-600}; - --gray-700: #{$gray-700}; - --gray-800: #{$gray-800}; - --gray-900: #{$gray-900}; - --gray-950: #{$gray-950}; - - --green-50: #{$green-50}; - --green-100: #{$green-100}; - --green-200: #{$green-200}; - --green-300: #{$green-300}; - --green-400: #{$green-400}; - --green-500: #{$green-500}; - --green-600: #{$green-600}; - --green-700: #{$green-700}; - --green-800: #{$green-800}; - --green-900: #{$green-900}; - --green-950: #{$green-950}; - - --blue-50: #{$blue-50}; - --blue-100: #{$blue-100}; - --blue-200: #{$blue-200}; - --blue-300: #{$blue-300}; - --blue-400: #{$blue-400}; - --blue-500: #{$blue-500}; - --blue-600: #{$blue-600}; - --blue-700: #{$blue-700}; - --blue-800: #{$blue-800}; - --blue-900: #{$blue-900}; - --blue-950: #{$blue-950}; - - --orange-50: #{$orange-50}; - --orange-100: #{$orange-100}; - --orange-200: #{$orange-200}; - --orange-300: #{$orange-300}; - --orange-400: #{$orange-400}; - --orange-500: #{$orange-500}; - --orange-600: #{$orange-600}; - --orange-700: #{$orange-700}; - --orange-800: #{$orange-800}; - --orange-900: #{$orange-900}; - --orange-950: #{$orange-950}; - - --red-50: #{$red-50}; - --red-100: #{$red-100}; - --red-200: #{$red-200}; - --red-300: #{$red-300}; - --red-400: #{$red-400}; - --red-500: #{$red-500}; - --red-600: #{$red-600}; - --red-700: #{$red-700}; - --red-800: #{$red-800}; - --red-900: #{$red-900}; - --red-950: #{$red-950}; - - --indigo-50: #{$indigo-50}; - --indigo-100: #{$indigo-100}; - --indigo-200: #{$indigo-200}; - --indigo-300: #{$indigo-300}; - --indigo-400: #{$indigo-400}; - --indigo-500: #{$indigo-500}; - --indigo-600: #{$indigo-600}; - --indigo-700: #{$indigo-700}; - --indigo-800: #{$indigo-800}; - --indigo-900: #{$indigo-900}; - --indigo-950: #{$indigo-950}; - - --purple-50: #{$purple-50}; - --purple-100: #{$purple-100}; - --purple-200: #{$purple-200}; - --purple-300: #{$purple-300}; - --purple-400: #{$purple-400}; - --purple-500: #{$purple-500}; - --purple-600: #{$purple-600}; - --purple-700: #{$purple-700}; - --purple-800: #{$purple-800}; - --purple-900: #{$purple-900}; - --purple-950: #{$purple-950}; - - --dark-icon-color-purple-1: #524a68; - --dark-icon-color-purple-2: #715bae; - --dark-icon-color-purple-3: #9a79f7; - --dark-icon-color-orange-1: #665349; - --dark-icon-color-orange-2: #b37a5d; - - --gl-text-color: #{$gray-900}; - --border-color: #{$border-color}; - - --white: #{$white}; - --black: #{$black}; - --gray-light: #{$gray-50}; - - --svg-status-bg: #{$white}; - - .gl-button.gl-button, - .gl-button.gl-button.btn-block { - &.btn-default, - &.btn-dashed, - &.btn-info, - &.btn-success, - &.btn-danger, - &.btn-confirm { - &-tertiary { - mix-blend-mode: screen; - } - } - } - - .gl-datepicker-theme { - .pika-prev, - .pika-next { - filter: invert(0.9); - } - - .is-selected > .pika-button { - color: $gray-900; - } - - :not(.is-selected) > .pika-button:hover { - background-color: $gray-200; - } - } -} +$data-viz-blue-50: #2a2b59; +$data-viz-blue-100: #303470; +$data-viz-blue-200: #374291; +$data-viz-blue-300: #3f51ae; +$data-viz-blue-400: #4e65cd; +$data-viz-blue-500: #617ae2; +$data-viz-blue-600: #7992f5; +$data-viz-blue-700: #97acff; +$data-viz-blue-800: #b7c6ff; +$data-viz-blue-900: #d2dcff; +$data-viz-blue-950: #e9ebff; $border-white-normal: $border-color; @@ -265,11 +141,3 @@ $line-removed-dark: $red-200; $well-expand-item: $gray-200; $well-inner-border: $gray-200; - -$calendar-activity-colors: ( - #404040, - #1e23a8, - #445cf2, - #97acff, - #e9ebff -); diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss index a0d19c3de2a..bb97261a1ca 100644 --- a/app/assets/stylesheets/themes/dark_mode_overrides.scss +++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss @@ -2,6 +2,149 @@ @import 'page_bundles/mixins_and_variables_and_functions'; @import './themes/theme_helper'; +:root { + color-scheme: dark; + --gray-10: #{$gray-10}; + --gray-50: #{$gray-50}; + --gray-100: #{$gray-100}; + --gray-200: #{$gray-200}; + --gray-300: #{$gray-300}; + --gray-400: #{$gray-400}; + --gray-500: #{$gray-500}; + --gray-600: #{$gray-600}; + --gray-700: #{$gray-700}; + --gray-800: #{$gray-800}; + --gray-900: #{$gray-900}; + --gray-950: #{$gray-950}; + + --green-50: #{$green-50}; + --green-100: #{$green-100}; + --green-200: #{$green-200}; + --green-300: #{$green-300}; + --green-400: #{$green-400}; + --green-500: #{$green-500}; + --green-600: #{$green-600}; + --green-700: #{$green-700}; + --green-800: #{$green-800}; + --green-900: #{$green-900}; + --green-950: #{$green-950}; + + --blue-50: #{$blue-50}; + --blue-100: #{$blue-100}; + --blue-200: #{$blue-200}; + --blue-300: #{$blue-300}; + --blue-400: #{$blue-400}; + --blue-500: #{$blue-500}; + --blue-600: #{$blue-600}; + --blue-700: #{$blue-700}; + --blue-800: #{$blue-800}; + --blue-900: #{$blue-900}; + --blue-950: #{$blue-950}; + + --orange-50: #{$orange-50}; + --orange-100: #{$orange-100}; + --orange-200: #{$orange-200}; + --orange-300: #{$orange-300}; + --orange-400: #{$orange-400}; + --orange-500: #{$orange-500}; + --orange-600: #{$orange-600}; + --orange-700: #{$orange-700}; + --orange-800: #{$orange-800}; + --orange-900: #{$orange-900}; + --orange-950: #{$orange-950}; + + --red-50: #{$red-50}; + --red-100: #{$red-100}; + --red-200: #{$red-200}; + --red-300: #{$red-300}; + --red-400: #{$red-400}; + --red-500: #{$red-500}; + --red-600: #{$red-600}; + --red-700: #{$red-700}; + --red-800: #{$red-800}; + --red-900: #{$red-900}; + --red-950: #{$red-950}; + + --indigo-50: #{$indigo-50}; + --indigo-100: #{$indigo-100}; + --indigo-200: #{$indigo-200}; + --indigo-300: #{$indigo-300}; + --indigo-400: #{$indigo-400}; + --indigo-500: #{$indigo-500}; + --indigo-600: #{$indigo-600}; + --indigo-700: #{$indigo-700}; + --indigo-800: #{$indigo-800}; + --indigo-900: #{$indigo-900}; + --indigo-950: #{$indigo-950}; + + --purple-50: #{$purple-50}; + --purple-100: #{$purple-100}; + --purple-200: #{$purple-200}; + --purple-300: #{$purple-300}; + --purple-400: #{$purple-400}; + --purple-500: #{$purple-500}; + --purple-600: #{$purple-600}; + --purple-700: #{$purple-700}; + --purple-800: #{$purple-800}; + --purple-900: #{$purple-900}; + --purple-950: #{$purple-950}; + + --dark-icon-color-purple-1: #524a68; + --dark-icon-color-purple-2: #715bae; + --dark-icon-color-purple-3: #9a79f7; + --dark-icon-color-orange-1: #665349; + --dark-icon-color-orange-2: #b37a5d; + + --gl-text-color: #{$gray-900}; + --border-color: #{$border-color}; + + --white: #{$white}; + --black: #{$black}; + --gray-light: #{$gray-50}; + + --svg-status-bg: #{$white}; +} + +body.gl-dark { + // redefine some colors and values to prevent sourcegraph conflicts + color-scheme: dark; + --gray-10: #{$gray-10}; + --border-color: #{$border-color}; + --white: #{$white}; + --black: #{$black}; +} + +.gl-dark { + .gl-button.gl-button, + .gl-button.gl-button.btn-block { + &.btn-default, + &.btn-dashed, + &.btn-info, + &.btn-success, + &.btn-danger, + &.btn-confirm { + &-tertiary { + mix-blend-mode: screen; + } + } + } + + .gl-datepicker-theme { + .pika-prev, + .pika-next { + filter: invert(0.9); + } + + .is-selected > .pika-button { + color: $gray-900; + } + + :not(.is-selected) > .pika-button:hover { + background-color: $gray-200; + } + } +} + // Some hacks and overrides for things that don't properly support dark mode .gl-label { filter: brightness(0.9) contrast(1.1); diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 4be4fc82d04..714dd932147 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -236,6 +236,13 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709 } } +// TODO: Remove once https: //gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/3198 is merged +.gl-sm-ml-5 { + @include gl-media-breakpoint-up(sm) { + @include gl-ml-5; + } +} + /* End gitlab-ui#1709 */ /* @@ -251,3 +258,8 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709 .gl-flex-flow-row-wrap { flex-flow: row wrap; } + +// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2098 +.gl-max-w-0 { + max-width: 0; +} diff --git a/app/channels/graphql_channel.rb b/app/channels/graphql_channel.rb index d364cc2b64b..ae37be85da8 100644 --- a/app/channels/graphql_channel.rb +++ b/app/channels/graphql_channel.rb @@ -25,9 +25,7 @@ class GraphqlChannel < ApplicationCable::Channel # rubocop:disable Gitlab/Namesp # Track the subscription here so we can remove it # on unsubscribe. - if result.context[:subscription_id] - @subscription_ids << result.context[:subscription_id] - end + @subscription_ids << result.context[:subscription_id] if result.context[:subscription_id] transmit(payload) end diff --git a/app/components/diffs/stats_component.rb b/app/components/diffs/stats_component.rb index 74788133aa2..407c3ca4e58 100644 --- a/app/components/diffs/stats_component.rb +++ b/app/components/diffs/stats_component.rb @@ -14,14 +14,14 @@ module Diffs def diff_files_data diffs_map = @diff_files.map do |f| { - href: "##{helpers.hexdigest(f.file_path)}", - title: f.new_path, - name: f.file_path, - path: diff_file_path_text(f), - icon: diff_file_changed_icon(f), - iconColor: "#{diff_file_changed_icon_color(f)}", - added: f.added_lines, - removed: f.removed_lines + href: "##{helpers.hexdigest(f.file_path)}", + title: f.new_path, + name: f.file_path, + path: diff_file_path_text(f), + icon: diff_file_changed_icon(f), + iconColor: diff_file_changed_icon_color(f).to_s, + added: f.added_lines, + removed: f.removed_lines } end diff --git a/app/components/pajamas/button_component.rb b/app/components/pajamas/button_component.rb index b2dd798b718..cdfd201bfb8 100644 --- a/app/components/pajamas/button_component.rb +++ b/app/components/pajamas/button_component.rb @@ -65,7 +65,7 @@ module Pajamas classes.push(VARIANT_CLASSES[@variant]) unless NON_CATEGORY_VARIANTS.include?(@variant) || @category == :primary - classes.push(VARIANT_CLASSES[@variant] + '-' + CATEGORY_CLASSES[@category]) + classes.push("#{VARIANT_CLASSES[@variant]}-#{CATEGORY_CLASSES[@category]}") end classes.push(@button_options[:class]) diff --git a/app/controllers/abuse_reports_controller.rb b/app/controllers/abuse_reports_controller.rb index 0de2115d4d6..80aca7e21ce 100644 --- a/app/controllers/abuse_reports_controller.rb +++ b/app/controllers/abuse_reports_controller.rb @@ -3,7 +3,7 @@ class AbuseReportsController < ApplicationController before_action :set_user, only: [:new] - feature_category :users + feature_category :insider_threat def new @abuse_report = AbuseReport.new diff --git a/app/controllers/admin/abuse_reports_controller.rb b/app/controllers/admin/abuse_reports_controller.rb index 6f80ed3c172..5357558434e 100644 --- a/app/controllers/admin/abuse_reports_controller.rb +++ b/app/controllers/admin/abuse_reports_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Admin::AbuseReportsController < Admin::ApplicationController - feature_category :users + feature_category :insider_threat def index @abuse_reports = AbuseReportsFinder.new(params).execute diff --git a/app/controllers/admin/application_settings/appearances_controller.rb b/app/controllers/admin/application_settings/appearances_controller.rb index cf765c96a8f..1a8447185a7 100644 --- a/app/controllers/admin/application_settings/appearances_controller.rb +++ b/app/controllers/admin/application_settings/appearances_controller.rb @@ -68,6 +68,7 @@ class Admin::ApplicationSettings::AppearancesController < Admin::ApplicationCont def allowed_appearance_params %i[ title + short_title description logo logo_cache diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index ec9441c2b9b..b8c1bc266f7 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -40,9 +40,9 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController feature_category :pages, [:lets_encrypt_terms_of_service] feature_category :error_tracking, [:reset_error_tracking_access_token] - VALID_SETTING_PANELS = %w(general repository + VALID_SETTING_PANELS = %w[general repository ci_cd reporting metrics_and_profiling - network preferences).freeze + network preferences].freeze # The current size of a sidekiq job's jid is 24 characters. The size of the # jid is an internal detail of Sidekiq, and they do not guarantee that it'll @@ -150,9 +150,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController } end - if @application_setting.self_monitoring_project_id.present? - return render status: :ok, json: self_monitoring_data - end + return render status: :ok, json: self_monitoring_data if @application_setting.self_monitoring_project_id.present? render status: :bad_request, json: { message: _('Self-monitoring project does not exist. Please check logs ' \ @@ -236,7 +234,9 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController params[:application_setting][:restricted_visibility_levels]&.delete("") if params[:application_setting].key?(:required_instance_ci_template) - params[:application_setting][:required_instance_ci_template] = nil if params[:application_setting][:required_instance_ci_template].empty? + if params[:application_setting][:required_instance_ci_template].empty? + params[:application_setting][:required_instance_ci_template] = nil + end end remove_blank_params_for!(:elasticsearch_aws_secret_access_key, :eks_secret_access_key) @@ -290,9 +290,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController .new(@application_setting, current_user, application_setting_params) .execute - if recheck_user_consent? - session[:ask_for_usage_stats_consent] = current_user.requires_usage_stats_consent? - end + session[:ask_for_usage_stats_consent] = current_user.requires_usage_stats_consent? if recheck_user_consent? redirect_path = referer_path(request) || general_admin_application_settings_path diff --git a/app/controllers/admin/background_jobs_controller.rb b/app/controllers/admin/background_jobs_controller.rb index 4eda35d66f6..43d2c983823 100644 --- a/app/controllers/admin/background_jobs_controller.rb +++ b/app/controllers/admin/background_jobs_controller.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true -class Admin::BackgroundJobsController < Admin::ApplicationController - feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned +module Admin + class BackgroundJobsController < ApplicationController + feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned + end end diff --git a/app/controllers/admin/background_migrations_controller.rb b/app/controllers/admin/background_migrations_controller.rb index c6c9e0ced22..b904196c5ab 100644 --- a/app/controllers/admin/background_migrations_controller.rb +++ b/app/controllers/admin/background_migrations_controller.rb @@ -1,66 +1,68 @@ # frozen_string_literal: true -class Admin::BackgroundMigrationsController < Admin::ApplicationController - feature_category :database - urgency :low - - around_action :support_multiple_databases - - def index - @relations_by_tab = { - 'queued' => batched_migration_class.queued.queue_order, - 'failed' => batched_migration_class.with_status(:failed).queue_order, - 'finished' => batched_migration_class.with_status(:finished).queue_order.reverse_order - } - - @current_tab = @relations_by_tab.key?(params[:tab]) ? params[:tab] : 'queued' - @migrations = @relations_by_tab[@current_tab].page(params[:page]) - @successful_rows_counts = batched_migration_class.successful_rows_counts(@migrations.map(&:id)) - @databases = Gitlab::Database.db_config_names - end +module Admin + class BackgroundMigrationsController < ApplicationController + feature_category :database + urgency :low + + around_action :support_multiple_databases + + def index + @relations_by_tab = { + 'queued' => batched_migration_class.queued.queue_order, + 'failed' => batched_migration_class.with_status(:failed).queue_order, + 'finished' => batched_migration_class.with_status(:finished).queue_order.reverse_order + } + + @current_tab = @relations_by_tab.key?(params[:tab]) ? params[:tab] : 'queued' + @migrations = @relations_by_tab[@current_tab].page(params[:page]) + @successful_rows_counts = batched_migration_class.successful_rows_counts(@migrations.map(&:id)) + @databases = Gitlab::Database.db_config_names + end - def show - @migration = batched_migration_class.find(params[:id]) + def show + @migration = batched_migration_class.find(params[:id]) - @failed_jobs = @migration.batched_jobs.with_status(:failed).page(params[:page]) - end + @failed_jobs = @migration.batched_jobs.with_status(:failed).page(params[:page]) + end - def pause - migration = batched_migration_class.find(params[:id]) - migration.pause! + def pause + migration = batched_migration_class.find(params[:id]) + migration.pause! - redirect_back fallback_location: { action: 'index' } - end + redirect_back fallback_location: { action: 'index' } + end - def resume - migration = batched_migration_class.find(params[:id]) - migration.execute! + def resume + migration = batched_migration_class.find(params[:id]) + migration.execute! - redirect_back fallback_location: { action: 'index' } - end + redirect_back fallback_location: { action: 'index' } + end - def retry - migration = batched_migration_class.find(params[:id]) - migration.retry_failed_jobs! if migration.failed? + def retry + migration = batched_migration_class.find(params[:id]) + migration.retry_failed_jobs! if migration.failed? - redirect_back fallback_location: { action: 'index' } - end + redirect_back fallback_location: { action: 'index' } + end - private + private - def support_multiple_databases - Gitlab::Database::SharedModel.using_connection(base_model.connection) do - yield + def support_multiple_databases + Gitlab::Database::SharedModel.using_connection(base_model.connection) do + yield + end end - end - def base_model - @selected_database = params[:database] || Gitlab::Database::MAIN_DATABASE_NAME + def base_model + @selected_database = params[:database] || Gitlab::Database::MAIN_DATABASE_NAME - Gitlab::Database.database_base_models[@selected_database] - end + Gitlab::Database.database_base_models[@selected_database] + end - def batched_migration_class - @batched_migration_class ||= Gitlab::Database::BackgroundMigration::BatchedMigration + def batched_migration_class + @batched_migration_class ||= Gitlab::Database::BackgroundMigration::BatchedMigration + end end end diff --git a/app/controllers/admin/batched_jobs_controller.rb b/app/controllers/admin/batched_jobs_controller.rb index 0a00ba13dc8..10b5f68d630 100644 --- a/app/controllers/admin/batched_jobs_controller.rb +++ b/app/controllers/admin/batched_jobs_controller.rb @@ -1,28 +1,30 @@ # frozen_string_literal: true -class Admin::BatchedJobsController < Admin::ApplicationController - feature_category :database - urgency :low +module Admin + class BatchedJobsController < ApplicationController + feature_category :database + urgency :low - around_action :support_multiple_databases + around_action :support_multiple_databases - def show - @job = Gitlab::Database::BackgroundMigration::BatchedJob.find(params[:id]) + def show + @job = Gitlab::Database::BackgroundMigration::BatchedJob.find(params[:id]) - @transition_logs = @job.batched_job_transition_logs - end + @transition_logs = @job.batched_job_transition_logs + end - private + private - def support_multiple_databases - Gitlab::Database::SharedModel.using_connection(base_model.connection) do - yield + def support_multiple_databases + Gitlab::Database::SharedModel.using_connection(base_model.connection) do + yield + end end - end - def base_model - @selected_database = params[:database] || Gitlab::Database::MAIN_DATABASE_NAME + def base_model + @selected_database = params[:database] || Gitlab::Database::MAIN_DATABASE_NAME - Gitlab::Database.database_base_models[@selected_database] + Gitlab::Database.database_base_models[@selected_database] + end end end diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb index bdf0c6aedb9..093c5667a24 100644 --- a/app/controllers/admin/broadcast_messages_controller.rb +++ b/app/controllers/admin/broadcast_messages_controller.rb @@ -1,104 +1,106 @@ # frozen_string_literal: true -class Admin::BroadcastMessagesController < Admin::ApplicationController - include BroadcastMessagesHelper +module Admin + class BroadcastMessagesController < ApplicationController + include BroadcastMessagesHelper - before_action :find_broadcast_message, only: [:edit, :update, :destroy] - before_action :find_broadcast_messages, only: [:index, :create] - before_action :push_features, only: [:index, :edit] + before_action :find_broadcast_message, only: [:edit, :update, :destroy] + before_action :find_broadcast_messages, only: [:index, :create] + before_action :push_features, only: [:index, :edit] - feature_category :onboarding - urgency :low + feature_category :onboarding + urgency :low - def index - @broadcast_message = BroadcastMessage.new - end - - def edit - end + def index + @broadcast_message = BroadcastMessage.new + end - def create - @broadcast_message = BroadcastMessage.new(broadcast_message_params) - success = @broadcast_message.save + def edit + end - respond_to do |format| - format.json do - if success - render json: @broadcast_message, status: :ok - else - render json: { errors: @broadcast_message.errors.full_messages }, status: :bad_request + def create + @broadcast_message = BroadcastMessage.new(broadcast_message_params) + success = @broadcast_message.save + + respond_to do |format| + format.json do + if success + render json: @broadcast_message, status: :ok + else + render json: { errors: @broadcast_message.errors.full_messages }, status: :bad_request + end end - end - format.html do - if success - redirect_to admin_broadcast_messages_path, notice: _('Broadcast Message was successfully created.') - else - render :index + format.html do + if success + redirect_to admin_broadcast_messages_path, notice: _('Broadcast Message was successfully created.') + else + render :index + end end end end - end - def update - success = @broadcast_message.update(broadcast_message_params) + def update + success = @broadcast_message.update(broadcast_message_params) - respond_to do |format| - format.json do - if success - render json: @broadcast_message, status: :ok - else - render json: { errors: @broadcast_message.errors.full_messages }, status: :bad_request + respond_to do |format| + format.json do + if success + render json: @broadcast_message, status: :ok + else + render json: { errors: @broadcast_message.errors.full_messages }, status: :bad_request + end end - end - format.html do - if success - redirect_to admin_broadcast_messages_path, notice: _('Broadcast Message was successfully updated.') - else - render :edit + format.html do + if success + redirect_to admin_broadcast_messages_path, notice: _('Broadcast Message was successfully updated.') + else + render :edit + end end end end - end - def destroy - @broadcast_message.destroy + def destroy + @broadcast_message.destroy - respond_to do |format| - format.html { redirect_back_or_default(default: { action: 'index' }) } - format.js { head :ok } + respond_to do |format| + format.html { redirect_back_or_default(default: { action: 'index' }) } + format.js { head :ok } + end end - end - def preview - @broadcast_message = BroadcastMessage.new(broadcast_message_params) - render partial: 'admin/broadcast_messages/preview' - end + def preview + @broadcast_message = BroadcastMessage.new(broadcast_message_params) + render partial: 'admin/broadcast_messages/preview' + end - protected + protected - def find_broadcast_message - @broadcast_message = BroadcastMessage.find(params[:id]) - end + def find_broadcast_message + @broadcast_message = BroadcastMessage.find(params[:id]) + end - def find_broadcast_messages - @broadcast_messages = BroadcastMessage.order(ends_at: :desc).page(params[:page]) # rubocop: disable CodeReuse/ActiveRecord - end + def find_broadcast_messages + @broadcast_messages = BroadcastMessage.order(ends_at: :desc).page(params[:page]) # rubocop: disable CodeReuse/ActiveRecord + end - def broadcast_message_params - params.require(:broadcast_message) - .permit(%i( - theme - ends_at - message - starts_at - target_path - broadcast_type - dismissable - ), target_access_levels: []).reverse_merge!(target_access_levels: []) - end + def broadcast_message_params + params.require(:broadcast_message) + .permit(%i[ + theme + ends_at + message + starts_at + target_path + broadcast_type + dismissable + ], target_access_levels: []).reverse_merge!(target_access_levels: []) + end - def push_features - push_frontend_feature_flag(:vue_broadcast_messages, current_user) - push_frontend_feature_flag(:role_targeted_broadcast_messages, current_user) + def push_features + push_frontend_feature_flag(:vue_broadcast_messages, current_user) + push_frontend_feature_flag(:role_targeted_broadcast_messages, current_user) + end end end diff --git a/app/controllers/admin/ci/variables_controller.rb b/app/controllers/admin/ci/variables_controller.rb index 7d643435ddb..ef50d7362c4 100644 --- a/app/controllers/admin/ci/variables_controller.rb +++ b/app/controllers/admin/ci/variables_controller.rb @@ -1,50 +1,54 @@ # frozen_string_literal: true -class Admin::Ci::VariablesController < Admin::ApplicationController - feature_category :pipeline_authoring - - def show - respond_to do |format| - format.json { render_instance_variables } - end - end - - def update - service = Ci::UpdateInstanceVariablesService.new(variables_params) - - if service.execute - respond_to do |format| - format.json { render_instance_variables } +module Admin + module Ci + class VariablesController < ApplicationController + feature_category :pipeline_authoring + + def show + respond_to do |format| + format.json { render_instance_variables } + end end - else - respond_to do |format| - format.json { render_error(service.errors) } + + def update + service = ::Ci::UpdateInstanceVariablesService.new(variables_params) + + if service.execute + respond_to do |format| + format.json { render_instance_variables } + end + else + respond_to do |format| + format.json { render_error(service.errors) } + end + end end - end - end - private + private - def variables - @variables ||= Ci::InstanceVariable.all - end + def variables + @variables ||= ::Ci::InstanceVariable.all + end - def render_instance_variables - render status: :ok, - json: { - variables: Ci::InstanceVariableSerializer.new.represent(variables) - } - end + def render_instance_variables + render status: :ok, + json: { + variables: ::Ci::InstanceVariableSerializer.new.represent(variables) + } + end - def render_error(errors) - render status: :bad_request, json: errors - end + def render_error(errors) + render status: :bad_request, json: errors + end - def variables_params - params.permit(variables_attributes: Array(variable_params_attributes)) - end + def variables_params + params.permit(variables_attributes: Array(variable_params_attributes)) + end - def variable_params_attributes - %i[id variable_type key secret_value protected masked _destroy] + def variable_params_attributes + %i[id variable_type key secret_value protected masked raw _destroy] + end + end end end diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index 1395d4bb3b7..8005babe19e 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -51,6 +51,10 @@ class Admin::GroupsController < Admin::ApplicationController @group.build_admin_note unless @group.admin_note if @group.update(group_params) + unless Gitlab::Utils.to_boolean(group_params['runner_registration_enabled']) + Ci::Runners::ResetRegistrationTokenService.new(@group, current_user).execute + end + redirect_to [:admin, @group], notice: _('Group was successfully updated.') else render "edit" @@ -91,6 +95,7 @@ class Admin::GroupsController < Admin::ApplicationController :name, :path, :request_access_enabled, + :runner_registration_enabled, :visibility_level, :require_two_factor_authentication, :two_factor_grace_period, diff --git a/app/controllers/admin/plan_limits_controller.rb b/app/controllers/admin/plan_limits_controller.rb index 2cebc059830..ea52198432c 100644 --- a/app/controllers/admin/plan_limits_controller.rb +++ b/app/controllers/admin/plan_limits_controller.rb @@ -47,6 +47,7 @@ class Admin::PlanLimitsController < Admin::ApplicationController ci_needs_size_limit ci_registered_group_runners ci_registered_project_runners + pipeline_hierarchy_size ]) end end diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb index 3f3c3581555..9e841487508 100644 --- a/app/controllers/admin/projects_controller.rb +++ b/app/controllers/admin/projects_controller.rb @@ -13,9 +13,7 @@ class Admin::ProjectsController < Admin::ApplicationController params[:sort] ||= 'latest_activity_desc' @sort = params[:sort] - if params[:last_repository_check_failed].present? && params[:archived].nil? - params[:archived] = true - end + params[:archived] = true if params[:last_repository_check_failed].present? && params[:archived].nil? @projects = Admin::ProjectsFinder.new(params: params, current_user: current_user).execute @@ -57,9 +55,7 @@ class Admin::ProjectsController < Admin::ApplicationController namespace = Namespace.find_by(id: params[:new_namespace_id]) ::Projects::TransferService.new(@project, current_user, params.dup).execute(namespace) - if @project.errors[:new_namespace].present? - flash[:alert] = @project.errors[:new_namespace].first - end + flash[:alert] = @project.errors[:new_namespace].first if @project.errors[:new_namespace].present? @project.reset redirect_to admin_project_path(@project) diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb index 3a55fc4b951..180f4634136 100644 --- a/app/controllers/admin/spam_logs_controller.rb +++ b/app/controllers/admin/spam_logs_controller.rb @@ -5,7 +5,7 @@ class Admin::SpamLogsController < Admin::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def index - @spam_logs = SpamLog.order(id: :desc).page(params[:page]) + @spam_logs = SpamLog.includes(:user).order(id: :desc).page(params[:page]) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/controllers/admin/system_info_controller.rb b/app/controllers/admin/system_info_controller.rb index 41f95addc66..96fb73cedfe 100644 --- a/app/controllers/admin/system_info_controller.rb +++ b/app/controllers/admin/system_info_controller.rb @@ -59,11 +59,11 @@ class Admin::SystemInfoController < Admin::ApplicationController begin disk = Sys::Filesystem.stat(mount.mount_point) @disks.push({ - bytes_total: disk.bytes_total, - bytes_used: disk.bytes_used, - disk_name: mount.name, - mount_path: disk.path - }) + bytes_total: disk.bytes_total, + bytes_used: disk.bytes_used, + disk_name: mount.name, + mount_path: disk.path + }) rescue Sys::Filesystem::Error end end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 2c8b4888d5d..5f6e3f0062f 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -88,17 +88,25 @@ class Admin::UsersController < Admin::ApplicationController end def activate - return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user must be unblocked to be activated")) if user.blocked? + if user.blocked? + return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user must be unblocked to be activated")) + end user.activate redirect_back_or_admin_user(notice: _("Successfully activated")) end def deactivate - return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user cannot be deactivated")) if user.blocked? + if user.blocked? + return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user cannot be deactivated")) + end + return redirect_back_or_admin_user(notice: _("Successfully deactivated")) if user.deactivated? return redirect_back_or_admin_user(notice: _("Internal users cannot be deactivated")) if user.internal? - return redirect_back_or_admin_user(notice: _("The user you are trying to deactivate has been active in the past %{minimum_inactive_days} days and cannot be deactivated") % { minimum_inactive_days: Gitlab::CurrentSettings.deactivate_dormant_users_period }) unless user.can_be_deactivated? + + unless user.can_be_deactivated? + return redirect_back_or_admin_user(notice: _("The user you are trying to deactivate has been active in the past %{minimum_inactive_days} days and cannot be deactivated") % { minimum_inactive_days: Gitlab::CurrentSettings.deactivate_dormant_users_period }) + end user.deactivate redirect_back_or_admin_user(notice: _("Successfully deactivated")) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 4de6b5de42a..e64d3110c3a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -158,7 +158,7 @@ class ApplicationController < ActionController::Base protected def workhorse_excluded_content_types - @workhorse_excluded_content_types ||= %w(text/html application/json) + @workhorse_excluded_content_types ||= %w[text/html application/json] end def append_info_to_payload(payload) @@ -179,9 +179,7 @@ class ApplicationController < ActionController::Base payload[:queue_duration_s] = request.env[::Gitlab::Middleware::RailsQueueDuration::GITLAB_RAILS_QUEUE_DURATION_KEY] - if Feature.enabled?(:log_response_length) - payload[:response_bytes] = response.body_parts.sum(&:bytesize) - end + payload[:response_bytes] = response.body_parts.sum(&:bytesize) if Feature.enabled?(:log_response_length) store_cloudflare_headers!(payload, request) end @@ -349,9 +347,7 @@ class ApplicationController < ActionController::Base def check_password_expiration return if session[:impersonator_id] || !current_user&.allow_password_authentication? - if current_user&.password_expired? - redirect_to new_profile_password_path - end + redirect_to new_profile_password_path if current_user&.password_expired? end def active_user_check @@ -426,8 +422,8 @@ class ApplicationController < ActionController::Base # accepting the terms. redirect_path = if request.get? request.fullpath - else - URI(request.referer).path if request.referer + elsif request.referer + URI(request.referer).path end flash[:notice] = message @@ -529,7 +525,7 @@ class ApplicationController < ActionController::Base end def set_page_title_header - # Per https://tools.ietf.org/html/rfc5987, headers need to be ISO-8859-1, not UTF-8 + # Per https://www.rfc-editor.org/rfc/rfc5987, headers need to be ISO-8859-1, not UTF-8 response.headers['Page-Title'] = Addressable::URI.encode_component(page_title('GitLab')) end @@ -565,9 +561,7 @@ class ApplicationController < ActionController::Base session[:ask_for_usage_stats_consent] = current_user.requires_usage_stats_consent? - if session[:ask_for_usage_stats_consent] - disable_usage_stats - end + disable_usage_stats if session[:ask_for_usage_stats_consent] end def disable_usage_stats diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb index 817f82085e6..b4a36b7db22 100644 --- a/app/controllers/concerns/authenticates_with_two_factor.rb +++ b/app/controllers/concerns/authenticates_with_two_factor.rb @@ -23,6 +23,8 @@ module AuthenticatesWithTwoFactor session[:otp_user_id] = user.id session[:user_password_hash] = Digest::SHA256.hexdigest(user.encrypted_password) + + add_gon_variables push_frontend_feature_flag(:webauthn) if Feature.enabled?(:webauthn) diff --git a/app/controllers/concerns/controller_with_cross_project_access_check.rb b/app/controllers/concerns/controller_with_cross_project_access_check.rb index 3f72f092683..eace8e9464b 100644 --- a/app/controllers/concerns/controller_with_cross_project_access_check.rb +++ b/app/controllers/concerns/controller_with_cross_project_access_check.rb @@ -9,9 +9,7 @@ module ControllerWithCrossProjectAccessCheck end def cross_project_check - if Gitlab::CrossProjectAccess.find_check(self)&.should_run?(self) - authorize_cross_project_page! - end + authorize_cross_project_page! if Gitlab::CrossProjectAccess.find_check(self)&.should_run?(self) end def authorize_cross_project_page! diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb index b6ba1b13cc3..53bb11090c8 100644 --- a/app/controllers/concerns/creates_commit.rb +++ b/app/controllers/concerns/creates_commit.rb @@ -78,7 +78,7 @@ module CreatesCommit _("You can now submit a merge request to get this change into the original branch.") end - flash[:notice] += " " + mr_message + flash[:notice] += " #{mr_message}" end end end diff --git a/app/controllers/concerns/cycle_analytics_params.rb b/app/controllers/concerns/cycle_analytics_params.rb index 70bcefe339c..5199d879595 100644 --- a/app/controllers/concerns/cycle_analytics_params.rb +++ b/app/controllers/concerns/cycle_analytics_params.rb @@ -23,7 +23,10 @@ module CycleAnalyticsParams opts[:from] = params[:from] || start_date(params) opts[:to] = params[:to] if params[:to] opts[:end_event_filter] = params[:end_event_filter] if params[:end_event_filter] - opts[:use_aggregated_data_collector] = params[:use_aggregated_data_collector] if params[:use_aggregated_data_collector] + if params[:use_aggregated_data_collector] + opts[:use_aggregated_data_collector] = params[:use_aggregated_data_collector] + end + opts.merge!(params.slice(*::Gitlab::Analytics::CycleAnalytics::RequestParams::FINDER_PARAM_NAMES)) opts.merge!(date_range(params)) end diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb index b1b6e21644e..c8de041d5bd 100644 --- a/app/controllers/concerns/enforces_two_factor_authentication.rb +++ b/app/controllers/concerns/enforces_two_factor_authentication.rb @@ -10,19 +10,12 @@ module EnforcesTwoFactorAuthentication extend ActiveSupport::Concern - MFA_HELP_PAGE = Rails.application.routes.url_helpers.help_page_url( - 'user/profile/account/two_factor_authentication.html', - anchor: 'enable-two-factor-authentication' - ) - included do before_action :check_two_factor_requirement, except: [:route_not_found] # to include this in controllers inheriting from `ActionController::Metal` # we need to add this block - if respond_to?(:helper_method) - helper_method :two_factor_grace_period_expired?, :two_factor_skippable? - end + helper_method :two_factor_grace_period_expired?, :two_factor_skippable? if respond_to?(:helper_method) end def check_two_factor_requirement @@ -33,7 +26,7 @@ module EnforcesTwoFactorAuthentication when GraphqlController render_error( _("Authentication error: enable 2FA in your profile settings to continue using GitLab: %{mfa_help_page}") % - { mfa_help_page: MFA_HELP_PAGE }, + { mfa_help_page: mfa_help_page_url }, status: :unauthorized ) else @@ -84,6 +77,13 @@ module EnforcesTwoFactorAuthentication def two_factor_verifier @two_factor_verifier ||= Gitlab::Auth::TwoFactorAuthVerifier.new(current_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables end + + def mfa_help_page_url + Rails.application.routes.url_helpers.help_page_url( + 'user/profile/account/two_factor_authentication.html', + anchor: 'enable-two-factor-authentication' + ) + end end EnforcesTwoFactorAuthentication.prepend_mod_with('EnforcesTwoFactorAuthentication') diff --git a/app/controllers/concerns/impersonation.rb b/app/controllers/concerns/impersonation.rb index 539dd9ad69d..e562cf5dbe4 100644 --- a/app/controllers/concerns/impersonation.rb +++ b/app/controllers/concerns/impersonation.rb @@ -3,11 +3,11 @@ module Impersonation include Gitlab::Utils::StrongMemoize - SESSION_KEYS_TO_DELETE = %w( + SESSION_KEYS_TO_DELETE = %w[ github_access_token gitea_access_token gitlab_access_token bitbucket_token bitbucket_refresh_token bitbucket_server_personal_access_token bulk_import_gitlab_access_token fogbugz_token - ).freeze + ].freeze def current_user user = super diff --git a/app/controllers/concerns/import/github_oauth.rb b/app/controllers/concerns/import/github_oauth.rb index d53022aabf2..c233f5d09fa 100644 --- a/app/controllers/concerns/import/github_oauth.rb +++ b/app/controllers/concerns/import/github_oauth.rb @@ -53,6 +53,7 @@ module Import def authorize_url state = SecureRandom.base64(64) session[auth_state_key] = state + session[:auth_on_failure_path] = "#{new_project_path}#import_project" if Feature.enabled?(:remove_legacy_github_client) oauth_client.auth_code.authorize_url( redirect_uri: callback_import_url, diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb index 30de4a86bec..74d998503b7 100644 --- a/app/controllers/concerns/integrations/params.rb +++ b/app/controllers/concerns/integrations/params.rb @@ -88,7 +88,9 @@ module Integrations param_values = return_value[:integration] if param_values.is_a?(ActionController::Parameters) - if action_name == 'update' && integration.chat? && param_values['webhook'] == BaseChatNotification::SECRET_MASK + if %w[update test].include?(action_name) && integration.chat? && + param_values['webhook'] == BaseChatNotification::SECRET_MASK + param_values.delete('webhook') end diff --git a/app/controllers/concerns/invisible_captcha_on_signup.rb b/app/controllers/concerns/invisible_captcha_on_signup.rb index c7fd6d08744..b78869e02d0 100644 --- a/app/controllers/concerns/invisible_captcha_on_signup.rb +++ b/app/controllers/concerns/invisible_captcha_on_signup.rb @@ -13,7 +13,7 @@ module InvisibleCaptchaOnSignup invisible_captcha_honeypot_counter.increment log_request('Invisible_Captcha_Honeypot_Request') - head(200) + head(:ok) end def on_timestamp_spam_callback diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index bea184e44b9..0669f051457 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -146,13 +146,17 @@ module IssuableActions finder = Issuable::DiscussionsListService.new(current_user, issuable, finder_params_for_issuable) discussion_notes = finder.execute - response.headers['X-Next-Page-Cursor'] = finder.paginator.cursor_for_next_page if finder.paginator.present? && finder.paginator.has_next_page? + if finder.paginator.present? && finder.paginator.has_next_page? + response.headers['X-Next-Page-Cursor'] = finder.paginator.cursor_for_next_page + end case issuable when MergeRequest render_mr_discussions(discussion_notes, discussion_serializer, discussion_cache_context) when Issue - render json: discussion_serializer.represent(discussion_notes, context: self) if stale?(etag: [discussion_cache_context, discussion_notes]) + if stale?(etag: [discussion_cache_context, discussion_notes]) + render json: discussion_serializer.represent(discussion_notes, context: self) + end else render json: discussion_serializer.represent(discussion_notes, context: self) end @@ -173,7 +177,7 @@ module IssuableActions def render_cached_discussions(discussions, serializer, cache_context) render_cached(discussions, with: serializer, - cache_context: -> (_) { cache_context }, + cache_context: ->(_) { cache_context }, context: self) end @@ -230,15 +234,11 @@ module IssuableActions end def authorize_destroy_issuable! - unless can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable) - access_denied! - end + access_denied! unless can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable) end def authorize_admin_issuable! - unless can?(current_user, :"admin_#{resource_name}", parent) - access_denied! - end + access_denied! unless can?(current_user, :"admin_#{resource_name}", parent) end def authorize_update_issuable! diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index de38d26e3fe..7b0d8cf8dcb 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -14,7 +14,9 @@ module IssuableCollections private def show_alert_if_search_is_disabled - return if current_user || params[:search].blank? || !html_request? || Feature.disabled?(:disable_anonymous_search, type: :ops) + if current_user || params[:search].blank? || !html_request? || Feature.disabled?(:disable_anonymous_search, type: :ops) + return + end flash.now[:notice] = _('You must sign in to search for specific terms.') end diff --git a/app/controllers/concerns/issues_calendar.rb b/app/controllers/concerns/issues_calendar.rb index 51d6d3cf05a..692ac5e700b 100644 --- a/app/controllers/concerns/issues_calendar.rb +++ b/app/controllers/concerns/issues_calendar.rb @@ -16,9 +16,7 @@ module IssuesCalendar # the content as a file (even ignoring the Content-Disposition # header). We want to display the content inline when accessed # from GitLab, similarly to the RSS feed. - if request.referer&.start_with?(::Settings.gitlab.base_url) - response.headers['Content-Type'] = 'text/plain' - end + response.headers['Content-Type'] = 'text/plain' if request.referer&.start_with?(::Settings.gitlab.base_url) end end end diff --git a/app/controllers/concerns/labels_as_hash.rb b/app/controllers/concerns/labels_as_hash.rb index e428520f709..601d3bf50eb 100644 --- a/app/controllers/concerns/labels_as_hash.rb +++ b/app/controllers/concerns/labels_as_hash.rb @@ -16,9 +16,7 @@ module LabelsAsHash if already_set_labels.present? titles = already_set_labels.map(&:title) label_hashes.each do |hash| - if titles.include?(hash['title']) - hash[:set] = true - end + hash[:set] = true if titles.include?(hash['title']) end end end diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb index 97df3c7caea..1653b40bad5 100644 --- a/app/controllers/concerns/lfs_request.rb +++ b/app/controllers/concerns/lfs_request.rb @@ -78,25 +78,27 @@ module LfsRequest end def lfs_download_access? - strong_memoize(:lfs_download_access) do - ci? || lfs_deploy_token? || user_can_download_code? || build_can_download_code? || deploy_token_can_download_code? - end + ci? || lfs_deploy_token? || user_can_download_code? || build_can_download_code? || deploy_token_can_download_code? end + strong_memoize_attr :lfs_download_access?, :lfs_download_access def deploy_token_can_download_code? deploy_token.present? && - deploy_token.project == project && - deploy_token.active? && + deploy_token.has_access_to?(project) && deploy_token.read_repository? end def lfs_upload_access? - strong_memoize(:lfs_upload_access) do - next false unless has_authentication_ability?(:push_code) - next false if limit_exceeded? + return false unless has_authentication_ability?(:push_code) + return false if limit_exceeded? - lfs_deploy_token? || can?(user, :push_code, project) || can?(deploy_token, :push_code, project) - end + lfs_deploy_token? || can?(user, :push_code, +project) || can?(deploy_token, :push_code, project) || any_branch_allows_collaboration? + end + strong_memoize_attr :lfs_upload_access?, :lfs_upload_access + + def any_branch_allows_collaboration? + project.merge_requests_allowing_push_to_user(user).any? end def lfs_deploy_token? diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index 8a67b62f28b..28d0af7a118 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -40,17 +40,15 @@ module MembershipActions respond_to do |format| format.html do message = - begin - case membershipable - when Namespace - if skip_subresources - _("User was successfully removed from group.") - else - _("User was successfully removed from group and any subgroups and projects.") - end + case membershipable + when Namespace + if skip_subresources + _("User was successfully removed from group.") else - _("User was successfully removed from project.") + _("User was successfully removed from group and any subgroups and projects.") end + else + _("User was successfully removed from project.") end redirect_to members_page_url, notice: message diff --git a/app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb b/app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb index 65237b552ca..ea9fd2de961 100644 --- a/app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb +++ b/app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb @@ -12,9 +12,7 @@ module Metrics::Dashboard::PrometheusApiProxy variable_substitution_result = proxy_variable_substitution_service.new(proxyable, permit_params).execute - if variable_substitution_result[:status] == :error - return error_response(variable_substitution_result) - end + return error_response(variable_substitution_result) if variable_substitution_result[:status] == :error prometheus_result = ::Prometheus::ProxyService.new( proxyable, diff --git a/app/controllers/concerns/metrics_dashboard.rb b/app/controllers/concerns/metrics_dashboard.rb index 28d0692d748..d4e8e95e016 100644 --- a/app/controllers/concerns/metrics_dashboard.rb +++ b/app/controllers/concerns/metrics_dashboard.rb @@ -118,9 +118,7 @@ module MetricsDashboard def decoded_params params = metrics_dashboard_params - if params[:dashboard_path] - params[:dashboard_path] = CGI.unescape(params[:dashboard_path]) - end + params[:dashboard_path] = CGI.unescape(params[:dashboard_path]) if params[:dashboard_path] params end diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb index 0a859bd3af9..e1967c50d70 100644 --- a/app/controllers/concerns/milestone_actions.rb +++ b/app/controllers/concerns/milestone_actions.rb @@ -8,9 +8,9 @@ module MilestoneActions format.html { redirect_to milestone_redirect_path } format.json do render json: tabs_json("shared/milestones/_issues_tab", { - issues: @milestone.sorted_issues(current_user), # rubocop:disable Gitlab/ModuleWithInstanceVariables - show_project_name: Gitlab::Utils.to_boolean(params[:show_project_name]) - }) + issues: @milestone.sorted_issues(current_user), # rubocop:disable Gitlab/ModuleWithInstanceVariables + show_project_name: Gitlab::Utils.to_boolean(params[:show_project_name]) + }) end end end @@ -20,9 +20,9 @@ module MilestoneActions format.html { redirect_to milestone_redirect_path } format.json do render json: tabs_json("shared/milestones/_merge_requests_tab", { - merge_requests: @milestone.sorted_merge_requests(current_user).preload_milestoneish_associations, # rubocop:disable Gitlab/ModuleWithInstanceVariables - show_project_name: Gitlab::Utils.to_boolean(params[:show_project_name]) - }) + merge_requests: @milestone.sorted_merge_requests(current_user).preload_milestoneish_associations, # rubocop:disable Gitlab/ModuleWithInstanceVariables + show_project_name: Gitlab::Utils.to_boolean(params[:show_project_name]) + }) end end end @@ -32,8 +32,8 @@ module MilestoneActions format.html { redirect_to milestone_redirect_path } format.json do render json: tabs_json("shared/milestones/_participants_tab", { - users: @milestone.issue_participants_visible_by_user(current_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables - }) + users: @milestone.issue_participants_visible_by_user(current_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables + }) end end end @@ -46,10 +46,10 @@ module MilestoneActions milestone_labels = @milestone.issue_labels_visible_by_user(current_user) render json: tabs_json("shared/milestones/_labels_tab", { - labels: milestone_labels.map do |label| - label.present(issuable_subject: @milestone.resource_parent) - end - }) + labels: milestone_labels.map do |label| + label.present(issuable_subject: @milestone.resource_parent) + end + }) end end end diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index b595c3c6790..a41e2d840ac 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -89,9 +89,7 @@ module NotesActions # rubocop:enable Gitlab/ModuleWithInstanceVariables def destroy - if note.editable? - Notes::DestroyService.new(project, current_user).execute(note) - end + Notes::DestroyService.new(project, current_user).execute(note) if note.editable? respond_to do |format| format.js { head :ok } @@ -258,15 +256,14 @@ module NotesActions end def last_fetched_at - strong_memoize(:last_fetched_at) do - microseconds = request.headers['X-Last-Fetched-At'].to_i + microseconds = request.headers['X-Last-Fetched-At'].to_i - seconds = microseconds / MICROSECOND - frac = microseconds % MICROSECOND + seconds = microseconds / MICROSECOND + frac = microseconds % MICROSECOND - Time.zone.at(seconds, frac) - end + Time.zone.at(seconds, frac) end + strong_memoize_attr :last_fetched_at def notes_filter current_user&.notes_filter_for(params[:target_type]) @@ -285,23 +282,22 @@ module NotesActions end def note_project - strong_memoize(:note_project) do - next nil unless project + return unless project - note_project_id = params[:note_project_id] + note_project_id = params[:note_project_id] - the_project = - if note_project_id.present? - Project.find(note_project_id) - else - project - end + the_project = + if note_project_id.present? + Project.find(note_project_id) + else + project + end - next access_denied! unless can?(current_user, :create_note, the_project) + return access_denied! unless can?(current_user, :create_note, the_project) - the_project - end + the_project end + strong_memoize_attr :note_project def return_discussion? Gitlab::Utils.to_boolean(params[:return_discussion]) diff --git a/app/controllers/concerns/oauth_applications.rb b/app/controllers/concerns/oauth_applications.rb index 8e63cc391ff..5b6fe933fda 100644 --- a/app/controllers/concerns/oauth_applications.rb +++ b/app/controllers/concerns/oauth_applications.rb @@ -12,9 +12,7 @@ module OauthApplications def prepare_scopes scopes = params.fetch(:doorkeeper_application, {}).fetch(:scopes, nil) - if scopes - params[:doorkeeper_application][:scopes] = scopes.join(' ') - end + params[:doorkeeper_application][:scopes] = scopes.join(' ') if scopes end def set_created_session @@ -30,7 +28,7 @@ module OauthApplications end def permitted_params - %i{name redirect_uri scopes confidential} + %i[name redirect_uri scopes confidential] end def application_params diff --git a/app/controllers/concerns/observability/content_security_policy.rb b/app/controllers/concerns/observability/content_security_policy.rb new file mode 100644 index 00000000000..eccd1e1e3ef --- /dev/null +++ b/app/controllers/concerns/observability/content_security_policy.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Observability + module ContentSecurityPolicy + extend ActiveSupport::Concern + + included do + content_security_policy do |p| + next if p.directives.blank? || Gitlab::Observability.observability_url.blank? + + default_frame_src = p.directives['frame-src'] || p.directives['default-src'] + + # When ObservabilityUI is not authenticated, it needs to be able + # to redirect to the GL sign-in page, hence '/users/sign_in' and '/oauth/authorize' + frame_src_values = Array.wrap(default_frame_src) | [Gitlab::Observability.observability_url, + Gitlab::Utils.append_path(Gitlab.config.gitlab.url, +'/users/sign_in'), + Gitlab::Utils.append_path(Gitlab.config.gitlab.url, +'/oauth/authorize')] + + p.frame_src(*frame_src_values) + end + end + end +end diff --git a/app/controllers/concerns/page_limiter.rb b/app/controllers/concerns/page_limiter.rb index 362b02e5856..1d044a41899 100644 --- a/app/controllers/concerns/page_limiter.rb +++ b/app/controllers/concerns/page_limiter.rb @@ -44,10 +44,11 @@ module PageLimiter raise PageLimitNotANumberError unless max_page_number.is_a?(Integer) raise PageLimitNotSensibleError unless max_page_number > 0 - if params[:page].present? && params[:page].to_i > max_page_number - record_page_limit_interception - raise PageOutOfBoundsError, max_page_number - end + return if params[:page].blank? + return if params[:page].to_i <= max_page_number + + record_page_limit_interception + raise PageOutOfBoundsError, max_page_number end # By default just return a HTTP status code and an empty response diff --git a/app/controllers/concerns/paginated_collection.rb b/app/controllers/concerns/paginated_collection.rb index fcee4493314..94a52dd0f89 100644 --- a/app/controllers/concerns/paginated_collection.rb +++ b/app/controllers/concerns/paginated_collection.rb @@ -10,9 +10,7 @@ module PaginatedCollection out_of_range = collection.current_page > total_pages - if out_of_range - redirect_to(url_for(safe_params.merge(page: total_pages, only_path: true))) - end + redirect_to(url_for(safe_params.merge(page: total_pages, only_path: true))) if out_of_range out_of_range end diff --git a/app/controllers/concerns/preferred_language_switcher.rb b/app/controllers/concerns/preferred_language_switcher.rb index 9711e57cf7a..00cd0f9d1d5 100644 --- a/app/controllers/concerns/preferred_language_switcher.rb +++ b/app/controllers/concerns/preferred_language_switcher.rb @@ -16,3 +16,5 @@ module PreferredLanguageSwitcher Gitlab::CurrentSettings.default_preferred_language end end + +PreferredLanguageSwitcher.prepend_mod diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb index 7af114313a1..a7655efe7a9 100644 --- a/app/controllers/concerns/preview_markdown.rb +++ b/app/controllers/concerns/preview_markdown.rb @@ -45,7 +45,13 @@ module PreviewMarkdown when 'projects' then projects_filter_params when 'timeline_events' then timeline_events_filter_params else {} - end.merge(requested_path: params[:path], ref: params[:ref]) + end.merge( + requested_path: params[:path], + ref: params[:ref], + # Disable comments in markdown for IE browsers because comments in IE + # could allow script execution. + allow_comments: !browser.ie? + ) end # rubocop:enable Gitlab/ModuleWithInstanceVariables diff --git a/app/controllers/concerns/product_analytics_tracking.rb b/app/controllers/concerns/product_analytics_tracking.rb index dfa159ccfd7..b01320ce3ec 100644 --- a/app/controllers/concerns/product_analytics_tracking.rb +++ b/app/controllers/concerns/product_analytics_tracking.rb @@ -16,7 +16,7 @@ module ProductAnalyticsTracking end end - def track_custom_event(*controller_actions, name:, conditions: nil, action:, label:, destinations: [:redis_hll], &block) + def track_custom_event(*controller_actions, name:, action:, label:, conditions: nil, destinations: [:redis_hll], &block) custom_conditions = [:trackable_html_request?, *conditions] after_action only: controller_actions, if: custom_conditions do @@ -30,15 +30,15 @@ module ProductAnalyticsTracking def route_events_to(destinations, name, &block) track_unique_redis_hll_event(name, &block) if destinations.include?(:redis_hll) - if destinations.include?(:snowplow) && event_enabled?(name) - Gitlab::Tracking.event( - self.class.to_s, - name, - namespace: tracking_namespace_source, - user: current_user, - context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: name).to_context] - ) - end + return unless destinations.include?(:snowplow) && event_enabled?(name) + + Gitlab::Tracking.event( + self.class.to_s, + name, + namespace: tracking_namespace_source, + user: current_user, + context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: name).to_context] + ) end def route_custom_events_to(destinations, name, action, label, &block) @@ -64,30 +64,32 @@ module ProductAnalyticsTracking def event_enabled?(event) events_to_ff = { - g_analytics_valuestream: :route_hll_to_snowplow, - - i_search_paid: :route_hll_to_snowplow_phase2, - i_search_total: :route_hll_to_snowplow_phase2, - i_search_advanced: :route_hll_to_snowplow_phase2, - i_ecosystem_jira_service_list_issues: :route_hll_to_snowplow_phase2, - users_viewing_analytics_group_devops_adoption: :route_hll_to_snowplow_phase2, - i_analytics_dev_ops_adoption: :route_hll_to_snowplow_phase2, - i_analytics_dev_ops_score: :route_hll_to_snowplow_phase2, - p_analytics_merge_request: :route_hll_to_snowplow_phase2, - i_analytics_instance_statistics: :route_hll_to_snowplow_phase2, - g_analytics_contribution: :route_hll_to_snowplow_phase2, - p_analytics_pipelines: :route_hll_to_snowplow_phase2, - p_analytics_code_reviews: :route_hll_to_snowplow_phase2, - p_analytics_valuestream: :route_hll_to_snowplow_phase2, - p_analytics_insights: :route_hll_to_snowplow_phase2, - p_analytics_issues: :route_hll_to_snowplow_phase2, - p_analytics_repo: :route_hll_to_snowplow_phase2, - g_analytics_insights: :route_hll_to_snowplow_phase2, - g_analytics_issues: :route_hll_to_snowplow_phase2, - g_analytics_productivity: :route_hll_to_snowplow_phase2, - i_analytics_cohorts: :route_hll_to_snowplow_phase2 + g_analytics_valuestream: '', + + i_search_paid: :_phase2, + i_search_total: :_phase2, + i_search_advanced: :_phase2, + i_ecosystem_jira_service_list_issues: :_phase2, + users_viewing_analytics_group_devops_adoption: :_phase2, + i_analytics_dev_ops_adoption: :_phase2, + i_analytics_dev_ops_score: :_phase2, + p_analytics_merge_request: :_phase2, + i_analytics_instance_statistics: :_phase2, + g_analytics_contribution: :_phase2, + p_analytics_pipelines: :_phase2, + p_analytics_code_reviews: :_phase2, + p_analytics_valuestream: :_phase2, + p_analytics_insights: :_phase2, + p_analytics_issues: :_phase2, + p_analytics_repo: :_phase2, + g_analytics_insights: :_phase2, + g_analytics_issues: :_phase2, + g_analytics_productivity: :_phase2, + i_analytics_cohorts: :_phase2, + + g_compliance_dashboard: :_phase4 } - Feature.enabled?(events_to_ff[event.to_sym], tracking_namespace_source) + Feature.enabled?("route_hll_to_snowplow#{events_to_ff[event.to_sym]}", tracking_namespace_source) end end diff --git a/app/controllers/concerns/record_user_last_activity.rb b/app/controllers/concerns/record_user_last_activity.rb index 29164df4516..6ac87d8f27b 100644 --- a/app/controllers/concerns/record_user_last_activity.rb +++ b/app/controllers/concerns/record_user_last_activity.rb @@ -18,9 +18,8 @@ module RecordUserLastActivity def set_user_last_activity return unless request.get? return if Gitlab::Database.read_only? + return unless current_user && current_user.last_activity_on != Date.today - if current_user && current_user.last_activity_on != Date.today - Users::ActivityService.new(current_user).execute - end + Users::ActivityService.new(current_user).execute end end diff --git a/app/controllers/concerns/render_service_results.rb b/app/controllers/concerns/render_service_results.rb index 0149a71d9f5..83b880096be 100644 --- a/app/controllers/concerns/render_service_results.rb +++ b/app/controllers/concerns/render_service_results.rb @@ -5,25 +5,25 @@ module RenderServiceResults def success_response(result) render({ - status: result[:http_status], - json: result[:body] - }) + status: result[:http_status], + json: result[:body] + }) end def continue_polling_response render({ - status: :no_content, - json: { - status: _('processing'), - message: _('Not ready yet. Try again later.') - } - }) + status: :no_content, + json: { + status: _('processing'), + message: _('Not ready yet. Try again later.') + } + }) end def error_response(result) render({ - status: result[:http_status] || :bad_request, - json: { status: result[:status], message: result[:message] } - }) + status: result[:http_status] || :bad_request, + json: { status: result[:status], message: result[:message] } + }) end end diff --git a/app/controllers/concerns/renders_ldap_servers.rb b/app/controllers/concerns/renders_ldap_servers.rb index cc83ff47048..8c3d9fd4d5c 100644 --- a/app/controllers/concerns/renders_ldap_servers.rb +++ b/app/controllers/concerns/renders_ldap_servers.rb @@ -8,12 +8,10 @@ module RendersLdapServers end def ldap_servers - @ldap_servers ||= begin - if Gitlab::Auth::Ldap::Config.sign_in_enabled? - Gitlab::Auth::Ldap::Config.available_servers - else - [] - end - end + @ldap_servers ||= if Gitlab::Auth::Ldap::Config.sign_in_enabled? + Gitlab::Auth::Ldap::Config.available_servers + else + [] + end end end diff --git a/app/controllers/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb index e34d6b09c24..28e1fa473b3 100644 --- a/app/controllers/concerns/routable_actions.rb +++ b/app/controllers/concerns/routable_actions.rb @@ -46,13 +46,13 @@ module RoutableActions return unless request.get? canonical_path = routable.full_path - if canonical_path != routable_full_path - if !request.xhr? && request.format.html? && canonical_path.casecmp(routable_full_path) != 0 - flash[:notice] = "#{routable.class.to_s.titleize} '#{routable_full_path}' was moved to '#{canonical_path}'. Please update any links and bookmarks that may still have the old path." - end + return unless canonical_path != routable_full_path - redirect_to build_canonical_path(routable), status: :moved_permanently + if !request.xhr? && request.format.html? && canonical_path.casecmp(routable_full_path) != 0 + flash[:notice] = "#{routable.class.to_s.titleize} '#{routable_full_path}' was moved to '#{canonical_path}'. Please update any links and bookmarks that may still have the old path." end + + redirect_to build_canonical_path(routable), status: :moved_permanently end end diff --git a/app/controllers/concerns/snippets/blobs_actions.rb b/app/controllers/concerns/snippets/blobs_actions.rb index b510594ad63..2a0491b4df8 100644 --- a/app/controllers/concerns/snippets/blobs_actions.rb +++ b/app/controllers/concerns/snippets/blobs_actions.rb @@ -25,14 +25,13 @@ module Snippets::BlobsActions # rubocop:disable Gitlab/ModuleWithInstanceVariables def blob - strong_memoize(:blob) do - assign_ref_vars + assign_ref_vars - next unless @commit + return unless @commit - @repo.blob_at(@commit.id, @path) - end + @repo.blob_at(@commit.id, @path) end + strong_memoize_attr :blob # rubocop:enable Gitlab/ModuleWithInstanceVariables def ensure_blob @@ -40,11 +39,11 @@ module Snippets::BlobsActions end def ensure_repository - unless snippet.repo_exists? - Gitlab::AppLogger.error(message: "Snippet raw blob attempt with no repo", snippet: snippet.id) + return if snippet.repo_exists? - respond_422 - end + Gitlab::AppLogger.error(message: "Snippet raw blob attempt with no repo", snippet: snippet.id) + + respond_422 end def snippet_id diff --git a/app/controllers/concerns/sorting_preference.rb b/app/controllers/concerns/sorting_preference.rb index 6278b489028..300c1d6d779 100644 --- a/app/controllers/concerns/sorting_preference.rb +++ b/app/controllers/concerns/sorting_preference.rb @@ -45,9 +45,7 @@ module SortingPreference return sort_param if Gitlab::Database.read_only? - if user_preference[field] != sort_param - user_preference.update(field => sort_param) - end + user_preference.update(field => sort_param) if user_preference[field] != sort_param sort_param end diff --git a/app/controllers/concerns/sourcegraph_decorator.rb b/app/controllers/concerns/sourcegraph_decorator.rb index 061990a4361..4aeace1ca67 100644 --- a/app/controllers/concerns/sourcegraph_decorator.rb +++ b/app/controllers/concerns/sourcegraph_decorator.rb @@ -22,8 +22,8 @@ module SourcegraphDecorator return unless sourcegraph_enabled? gon.push({ - sourcegraph: { url: Gitlab::CurrentSettings.sourcegraph_url } - }) + sourcegraph: { url: Gitlab::CurrentSettings.sourcegraph_url } + }) end def sourcegraph_enabled? diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb index e98d36854f1..0ba13896631 100644 --- a/app/controllers/concerns/uploads_actions.rb +++ b/app/controllers/concerns/uploads_actions.rb @@ -5,7 +5,7 @@ module UploadsActions include Gitlab::Utils::StrongMemoize include SendFileUpload - UPLOAD_MOUNTS = %w(avatar attachment file logo header_logo favicon).freeze + UPLOAD_MOUNTS = %w[avatar attachment file logo header_logo favicon].freeze included do prepend_before_action :set_request_format_from_path_extension @@ -73,11 +73,11 @@ module UploadsActions def set_request_format_from_path_extension path = request.headers['action_dispatch.original_path'] || request.headers['PATH_INFO'] - if match = path&.match(/\.(\w+)\z/) - format = Mime[match.captures.first] + return unless match = path&.match(/\.(\w+)\z/) - request.format = format.symbol if format - end + format = Mime[match.captures.first] + + request.format = format.symbol if format end def content_disposition @@ -102,14 +102,13 @@ module UploadsActions end def uploader - strong_memoize(:uploader) do - if uploader_mounted? - model.public_send(upload_mount) # rubocop:disable GitlabSecurity/PublicSend - else - build_uploader_from_upload || build_uploader_from_params - end + if uploader_mounted? + model.public_send(upload_mount) # rubocop:disable GitlabSecurity/PublicSend + else + build_uploader_from_upload || build_uploader_from_params end end + strong_memoize_attr :uploader # rubocop: disable CodeReuse/ActiveRecord def build_uploader_from_upload @@ -163,8 +162,9 @@ module UploadsActions end def model - strong_memoize(:model) { find_model } + find_model end + strong_memoize_attr :model def workhorse_authorize_request? action_name == 'authorize' diff --git a/app/controllers/concerns/verifies_with_email.rb b/app/controllers/concerns/verifies_with_email.rb index ac1475597ff..3cada24a81a 100644 --- a/app/controllers/concerns/verifies_with_email.rb +++ b/app/controllers/concerns/verifies_with_email.rb @@ -28,7 +28,7 @@ module VerifiesWithEmail if user.unlock_token # Prompt for the token if it already has been set prompt_for_email_verification(user) - elsif user.access_locked? || !AuthenticationEvent.initial_login_or_known_ip_address?(user, request.ip) + elsif user.access_locked? || !trusted_ip_address?(user) # require email verification if: # - their account has been locked because of too many failed login attempts, or # - they have logged in before, but never from the current ip address @@ -68,7 +68,7 @@ module VerifiesWithEmail # After successful verification and calling sign_in, devise redirects the # user to this path. Override it to show the successful verified page. def after_sign_in_path_for(resource) - if action_name == 'create' && session[:verification_user_id] + if action_name == 'create' && session[:verification_user_id] == resource.id return users_successful_verification_path end @@ -133,6 +133,12 @@ module VerifiesWithEmail sign_in(user) end + def trusted_ip_address?(user) + return true if Feature.disabled?(:check_ip_address_for_email_verification) + + AuthenticationEvent.initial_login_or_known_ip_address?(user, request.ip) + end + def prompt_for_email_verification(user) session[:verification_user_id] = user.id self.resource = user diff --git a/app/controllers/concerns/vscode_cdn_csp.rb b/app/controllers/concerns/vscode_cdn_csp.rb new file mode 100644 index 00000000000..dc8cea966e5 --- /dev/null +++ b/app/controllers/concerns/vscode_cdn_csp.rb @@ -0,0 +1,17 @@ +# rubocop:disable Naming/FileName +# frozen_string_literal: true + +module VSCodeCDNCSP + extend ActiveSupport::Concern + + included do + content_security_policy do |policy| + next if policy.directives.blank? + + default_src = Array(policy.directives['default-src'] || []) + policy.directives['frame-src'] ||= default_src + policy.directives['frame-src'].concat(['https://*.vscode-cdn.net/']) + end + end +end +# rubocop:enable Naming/FileName diff --git a/app/controllers/concerns/web_hooks/hook_actions.rb b/app/controllers/concerns/web_hooks/hook_actions.rb index 75065ef9d24..f61600af951 100644 --- a/app/controllers/concerns/web_hooks/hook_actions.rb +++ b/app/controllers/concerns/web_hooks/hook_actions.rb @@ -18,7 +18,9 @@ module WebHooks self.hook = relation.new(hook_params) hook.save - unless hook.valid? + if hook.valid? + flash[:notice] = _('Webhook was created') + else self.hooks = relation.select(&:persisted?) flash[:alert] = hook.errors.full_messages.to_sentence.html_safe end @@ -28,8 +30,8 @@ module WebHooks def update if hook.update(hook_params) - flash[:notice] = _('Hook was successfully updated.') - redirect_to action: :index + flash[:notice] = _('Webhook was updated') + redirect_to action: :edit else render 'edit' end @@ -66,21 +68,14 @@ module WebHooks end def hook_param_names - param_names = %i[enable_ssl_verification token url push_events_branch_filter] - param_names.push(:branch_filter_strategy) if Feature.enabled?(:enhanced_webhook_support_regex) - param_names + %i[enable_ssl_verification token url push_events_branch_filter branch_filter_strategy] end def destroy_hook(hook) result = WebHooks::DestroyService.new(current_user).execute(hook) if result[:status] == :success - flash[:notice] = - if result[:async] - format(_("%{hook_type} was scheduled for deletion"), hook_type: hook.model_name.human) - else - format(_("%{hook_type} was deleted"), hook_type: hook.model_name.human) - end + flash[:notice] = result[:async] ? _('Webhook was scheduled for deletion') : _('Webhook was deleted') else flash[:alert] = result[:message] end diff --git a/app/controllers/dashboard/snippets_controller.rb b/app/controllers/dashboard/snippets_controller.rb index 5a885349467..d72a5e44b9f 100644 --- a/app/controllers/dashboard/snippets_controller.rb +++ b/app/controllers/dashboard/snippets_controller.rb @@ -7,7 +7,7 @@ class Dashboard::SnippetsController < Dashboard::ApplicationController skip_cross_project_access_check :index - feature_category :snippets + feature_category :source_code_management def index @snippet_counts = Snippets::CountService diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index d2434d4b0ba..3005d19f8ed 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -3,6 +3,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController include ActionView::Helpers::NumberHelper include PaginatedCollection + include Gitlab::Utils::StrongMemoize before_action :authorize_read_project!, only: :index before_action :authorize_read_group!, only: :index @@ -64,19 +65,19 @@ class Dashboard::TodosController < Dashboard::ApplicationController def authorize_read_project! project_id = params[:project_id] - if project_id.present? - project = Project.find(project_id) - render_404 unless can?(current_user, :read_project, project) - end + return unless project_id.present? + + project = Project.find(project_id) + render_404 unless can?(current_user, :read_project, project) end def authorize_read_group! group_id = params[:group_id] - if group_id.present? - group = Group.find(group_id) - render_404 unless can?(current_user, :read_group, group) - end + return unless group_id.present? + + group = Group.find(group_id) + render_404 unless can?(current_user, :read_group, group) end def find_todos @@ -99,14 +100,28 @@ class Dashboard::TodosController < Dashboard::ApplicationController end def todo_params - aliased_action_id( + aliased_params( params.permit(:action_id, :author_id, :project_id, :type, :sort, :state, :group_id) ) end + strong_memoize_attr :todo_params + + def aliased_params(original_params) + alias_issue_type(original_params) + alias_action_id(original_params) + + original_params + end + + def alias_issue_type(original_params) + return unless original_params[:type] == Issue.name + + original_params[:type] = [Issue.name, WorkItem.name] + end - def aliased_action_id(original_params) - return original_params unless original_params[:action_id].to_i == ::Todo::MENTIONED + def alias_action_id(original_params) + return unless original_params[:action_id].to_i == ::Todo::MENTIONED - original_params.merge(action_id: [::Todo::MENTIONED, ::Todo::DIRECTLY_ADDRESSED]) + original_params[:action_id] = [::Todo::MENTIONED, ::Todo::DIRECTLY_ADDRESSED] end end diff --git a/app/controllers/explore/snippets_controller.rb b/app/controllers/explore/snippets_controller.rb index 617cc2e7f3d..dee94b53cc1 100644 --- a/app/controllers/explore/snippets_controller.rb +++ b/app/controllers/explore/snippets_controller.rb @@ -3,7 +3,7 @@ class Explore::SnippetsController < Explore::ApplicationController include Gitlab::NoteableMetadata - feature_category :snippets + feature_category :source_code_management def index @snippets = SnippetsFinder.new(current_user, explore: true) diff --git a/app/controllers/google_api/authorizations_controller.rb b/app/controllers/google_api/authorizations_controller.rb index 5080ee5fbbe..536c5e347e7 100644 --- a/app/controllers/google_api/authorizations_controller.rb +++ b/app/controllers/google_api/authorizations_controller.rb @@ -48,14 +48,13 @@ module GoogleApi end def redirect_uri_from_session - strong_memoize(:redirect_uri_from_session) do - if params[:state].present? - session[session_key_for_redirect_uri(params[:state])] - else - nil - end + if params[:state].present? + session[session_key_for_redirect_uri(params[:state])] + else + nil end end + strong_memoize_attr :redirect_uri_from_session def session_key_for_redirect_uri(state) GoogleApi::CloudPlatform::Client.session_key_for_redirect_uri(state) diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 5ffd525c170..942cb9beed4 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -70,6 +70,12 @@ class GraphqlController < ApplicationController end end + rescue_from Gitlab::Auth::TooManyIps do |exception| + log_exception(exception) + + render_error(exception.message, status: :forbidden) + end + rescue_from Gitlab::Graphql::Variables::Invalid do |exception| render_error(exception.message, status: :unprocessable_entity) end diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb index f8cfa996447..5440908aee7 100644 --- a/app/controllers/groups/application_controller.rb +++ b/app/controllers/groups/application_controller.rb @@ -96,6 +96,28 @@ class Groups::ApplicationController < ApplicationController def validate_root_group! render_404 unless group.root? end + + def authorize_action!(action) + access_denied! unless can?(current_user, action, group) + end + + def respond_to_missing?(method, *args) + case method.to_s + when /\Aauthorize_(.*)!\z/ + true + else + super + end + end + + def method_missing(method_sym, *arguments, &block) + case method_sym.to_s + when /\Aauthorize_(.*)!\z/ + authorize_action!(Regexp.last_match(1).to_sym) + else + super + end + end end Groups::ApplicationController.prepend_mod_with('Groups::ApplicationController') diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index e1ba86220c7..6bb807be1c4 100644 --- a/app/controllers/groups/boards_controller.rb +++ b/app/controllers/groups/boards_controller.rb @@ -20,16 +20,14 @@ class Groups::BoardsController < Groups::ApplicationController private def board_finder - strong_memoize :board_finder do - Boards::BoardsFinder.new(parent, current_user, board_id: params[:id]) - end + Boards::BoardsFinder.new(parent, current_user, board_id: params[:id]) end + strong_memoize_attr :board_finder def board_create_service - strong_memoize :board_create_service do - Boards::CreateService.new(parent, current_user) - end + Boards::CreateService.new(parent, current_user) end + strong_memoize_attr :board_create_service def authorize_read_board! access_denied! unless can?(current_user, :read_issue_board, group) diff --git a/app/controllers/groups/dependency_proxy_for_containers_controller.rb b/app/controllers/groups/dependency_proxy_for_containers_controller.rb index 2e9e0b12d2f..427df9a7129 100644 --- a/app/controllers/groups/dependency_proxy_for_containers_controller.rb +++ b/app/controllers/groups/dependency_proxy_for_containers_controller.rb @@ -117,7 +117,7 @@ class Groups::DependencyProxyForContainersController < ::Groups::DependencyProxy end def blob_file_name - @blob_file_name ||= params[:sha].sub('sha256:', '') + '.gz' + @blob_file_name ||= "#{params[:sha].sub('sha256:', '')}.gz" end def manifest_file_name diff --git a/app/controllers/groups/observability_controller.rb b/app/controllers/groups/observability_controller.rb index 4b1f2b582ce..3baa5e830ff 100644 --- a/app/controllers/groups/observability_controller.rb +++ b/app/controllers/groups/observability_controller.rb @@ -1,18 +1,9 @@ # frozen_string_literal: true module Groups class ObservabilityController < Groups::ApplicationController - feature_category :tracing - - content_security_policy do |p| - next if p.directives.blank? - - default_frame_src = p.directives['frame-src'] || p.directives['default-src'] + include ::Observability::ContentSecurityPolicy - # When ObservabilityUI is not authenticated, it needs to be able to redirect to the GL sign-in page, hence 'self' - frame_src_values = Array.wrap(default_frame_src) | [observability_url, "'self'"] - - p.frame_src(*frame_src_values) - end + feature_category :tracing before_action :check_observability_allowed @@ -34,16 +25,8 @@ module Groups render 'observability', layout: 'group', locals: { base_layout: 'layouts/fullscreen' } end - def self.observability_url - Gitlab::Observability.observability_url - end - - def observability_url - self.class.observability_url - end - def check_observability_allowed - return render_404 unless observability_url.present? + return render_404 unless Gitlab::Observability.observability_url.present? render_404 unless can?(current_user, :read_observability, @group) end diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb index b1afac1f1c7..1dfa8cdf133 100644 --- a/app/controllers/groups/settings/ci_cd_controller.rb +++ b/app/controllers/groups/settings/ci_cd_controller.rb @@ -15,6 +15,8 @@ module Groups urgency :low def show + @entity = :group + @variable_limit = ::Plan.default.actual_limits.group_ci_variables end def update diff --git a/app/controllers/groups/usage_quotas_controller.rb b/app/controllers/groups/usage_quotas_controller.rb new file mode 100644 index 00000000000..29878f0001d --- /dev/null +++ b/app/controllers/groups/usage_quotas_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Groups + class UsageQuotasController < Groups::ApplicationController + before_action :authorize_read_usage_quotas! + before_action :verify_usage_quotas_enabled! + + feature_category :subscription_cost_management + urgency :low + + def index + # To be used in ee/app/controllers/ee/groups/usage_quotas_controller.rb + @seat_count_data = seat_count_data + end + + private + + def verify_usage_quotas_enabled! + render_404 unless Feature.enabled?(:usage_quotas_for_all_editions, group) + render_404 if group.has_parent? + end + + # To be overriden in ee/app/controllers/ee/groups/usage_quotas_controller.rb + def seat_count_data; end + end +end + +Groups::UsageQuotasController.prepend_mod diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb index 220b0b4509c..9ddf6c80c70 100644 --- a/app/controllers/groups/variables_controller.rb +++ b/app/controllers/groups/variables_controller.rb @@ -50,7 +50,7 @@ module Groups end def variable_params_attributes - %i[id variable_type key secret_value protected masked _destroy] + %i[id variable_type key secret_value protected masked raw _destroy] end def authorize_admin_build! diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 3f516c24a69..0a487bb2508 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -386,7 +386,7 @@ class GroupsController < Groups::ApplicationController override :has_project_list? def has_project_list? - %w(details show index).include?(action_name) + %w[details show index].include?(action_name) end def captcha_enabled? diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb index fcf6871d137..8a8c41e65b9 100644 --- a/app/controllers/ide_controller.rb +++ b/app/controllers/ide_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class IdeController < ApplicationController + include VSCodeCDNCSP include ClientsidePreviewCSP include StaticObjectExternalStorageCSP include Gitlab::Utils::StrongMemoize diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb index 75193309a4e..1d05cee02d4 100644 --- a/app/controllers/import/bitbucket_controller.rb +++ b/app/controllers/import/bitbucket_controller.rb @@ -49,6 +49,14 @@ class Import::BitbucketController < Import::BaseController namespace_path = params[:new_namespace].presence || repo_owner target_namespace = find_or_create_namespace(namespace_path, current_user) + Gitlab::Tracking.event( + self.class.name, + 'create', + label: 'import_access_level', + user: current_user, + extra: { user_role: user_role(current_user, target_namespace), import_type: 'bitbucket' } + ) + if current_user.can?(:create_projects, target_namespace) # The token in a session can be expired, we need to get most recent one because # Bitbucket::Connection class refreshes it. @@ -89,6 +97,21 @@ class Import::BitbucketController < Import::BaseController private + def user_role(user, namespace) + if current_user.id == namespace&.owner_id + Gitlab::Access.options_with_owner.key(Gitlab::Access::OWNER) + else + access_level = current_user&.group_members&.find_by(source_id: namespace&.id)&.access_level + + case access_level + when nil + 'Not a member' + else + Gitlab::Access.human_access(access_level) + end + end + end + def oauth_client @oauth_client ||= OAuth2::Client.new(provider.app_id, provider.app_secret, options) end diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb index 655fc7854fe..9a7118ce498 100644 --- a/app/controllers/import/bulk_imports_controller.rb +++ b/app/controllers/import/bulk_imports_controller.rb @@ -135,7 +135,7 @@ class Import::BulkImportsController < ApplicationController session[url_key], allow_localhost: allow_local_requests?, allow_local_network: allow_local_requests?, - schemes: %w(http https) + schemes: %w[http https] ) rescue Gitlab::UrlBlocker::BlockedUrlError => e clear_session_data diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb index 7b580234227..77043e174b4 100644 --- a/app/controllers/import/fogbugz_controller.rb +++ b/app/controllers/import/fogbugz_controller.rb @@ -114,7 +114,7 @@ class Import::FogbugzController < Import::BaseController end def user_map_params - params.permit(users: %w(name email gitlab_user)) + params.permit(users: %w[name email gitlab_user]) end def verify_fogbugz_import_enabled @@ -126,7 +126,7 @@ class Import::FogbugzController < Import::BaseController params[:uri], allow_localhost: allow_local_requests?, allow_local_network: allow_local_requests?, - schemes: %w(http https) + 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 4b4ac07b389..61e32650db3 100644 --- a/app/controllers/import/gitea_controller.rb +++ b/app/controllers/import/gitea_controller.rb @@ -16,12 +16,27 @@ class Import::GiteaController < Import::GithubController super end - # We need to re-expose controller's internal method 'status' as action. - # rubocop:disable Lint/UselessMethodDefinition def status - super + # Request repos to display error page if provider token is invalid + # Improving in https://gitlab.com/gitlab-org/gitlab/-/issues/25859 + client_repos + + respond_to do |format| + format.json do + render json: { imported_projects: serialized_imported_projects, + provider_repos: serialized_provider_repos, + incompatible_repos: serialized_incompatible_repos } + end + + format.html do + if params[:namespace_id].present? + @namespace = Namespace.find_by_id(params[:namespace_id]) + + render_404 unless current_user.can?(:create_projects, @namespace) + end + end + end end - # rubocop:enable Lint/UselessMethodDefinition protected @@ -61,7 +76,6 @@ class Import::GiteaController < Import::GithubController @client_repos ||= filtered(client.repos) end - override :client def client @client ||= Gitlab::LegacyGithubImport::Client.new(session[access_token_key], **client_options) end @@ -78,7 +92,7 @@ class Import::GiteaController < Import::GithubController provider_url, allow_localhost: allow_local_requests?, allow_local_network: allow_local_requests?, - schemes: %w(http https) + 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 92763e09ba3..cb58b5974ca 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -15,6 +15,8 @@ class Import::GithubController < Import::BaseController rescue_from Octokit::TooManyRequests, with: :provider_rate_limit rescue_from Gitlab::GithubImport::RateLimitError, with: :rate_limit_threshold_exceeded + delegate :client, to: :client_proxy, private: true + PAGE_LENGTH = 25 def new @@ -46,7 +48,22 @@ class Import::GithubController < Import::BaseController # Improving in https://gitlab.com/gitlab-org/gitlab-foss/issues/55585 client_repos - super + respond_to do |format| + format.json do + render json: { imported_projects: serialized_imported_projects, + provider_repos: serialized_provider_repos, + incompatible_repos: serialized_incompatible_repos, + page_info: client_repos_response[:page_info] } + end + + format.html do + if params[:namespace_id].present? + @namespace = Namespace.find_by_id(params[:namespace_id]) + + render_404 unless current_user.can?(:create_projects, @namespace) + end + end + end end def create @@ -126,24 +143,18 @@ class Import::GithubController < Import::BaseController end end - def client - @client ||= if Feature.enabled?(:remove_legacy_github_client) - Gitlab::GithubImport::Client.new(session[access_token_key]) - else - Gitlab::LegacyGithubImport::Client.new(session[access_token_key], **client_options) - end + def client_proxy + @client_proxy ||= Gitlab::GithubImport::Clients::Proxy.new( + session[access_token_key], client_options + ) + end + + def client_repos_response + @client_repos_response ||= client_proxy.repos(sanitized_filter_param, pagination_options) end def client_repos - @client_repos ||= if Feature.enabled?(:remove_legacy_github_client) - if sanitized_filter_param - client.search_repos_by_name(sanitized_filter_param, pagination_options)[:items] - else - client.repos(pagination_options) - end - else - filtered(client.repos) - end + client_repos_response[:repos] end def sanitized_filter_param @@ -213,6 +224,11 @@ class Import::GithubController < Import::BaseController def pagination_options { + before: params[:before].presence, + after: params[:after].presence, + first: PAGE_LENGTH, + # TODO: remove after rollout FF github_client_fetch_repos_via_graphql + # https://gitlab.com/gitlab-org/gitlab/-/issues/385649 page: [1, params[:page].to_i].max, per_page: PAGE_LENGTH } diff --git a/app/controllers/jira_connect/app_descriptor_controller.rb b/app/controllers/jira_connect/app_descriptor_controller.rb index 16bd73f5ab6..3c50d54fa10 100644 --- a/app/controllers/jira_connect/app_descriptor_controller.rb +++ b/app/controllers/jira_connect/app_descriptor_controller.rb @@ -28,7 +28,7 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController type: 'jwt' }, modules: modules, - scopes: %w(READ WRITE DELETE), + scopes: %w[READ WRITE DELETE], apiVersion: 1, apiMigrations: { 'context-qsh': true, @@ -76,7 +76,7 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController jiraDevelopmentTool: { actions: { createBranch: { - templateUrl: new_jira_connect_branch_url + '?issue_key={issue.key}&issue_summary={issue.summary}' + templateUrl: "#{new_jira_connect_branch_url}?issue_key={issue.key}&issue_summary={issue.summary}" } }, key: 'gitlab-development-tool', @@ -84,7 +84,7 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController name: { value: 'GitLab' }, url: HOME_URL, logoUrl: logo_url, - capabilities: %w(branch commit pull_request) + capabilities: %w[branch commit pull_request] } } end diff --git a/app/controllers/jira_connect/application_controller.rb b/app/controllers/jira_connect/application_controller.rb index b9f0ea795e1..e26d69314cd 100644 --- a/app/controllers/jira_connect/application_controller.rb +++ b/app/controllers/jira_connect/application_controller.rb @@ -3,11 +3,6 @@ class JiraConnect::ApplicationController < ApplicationController include Gitlab::Utils::StrongMemoize - CORS_ALLOWED_METHODS = { - '/-/jira_connect/oauth_application_id' => %i[GET OPTIONS], - '/-/jira_connect/subscriptions/*' => %i[DELETE OPTIONS] - }.freeze - skip_before_action :authenticate_user! skip_before_action :verify_authenticity_token before_action :verify_atlassian_jwt! @@ -65,25 +60,4 @@ class JiraConnect::ApplicationController < ApplicationController def auth_token params[:jwt] || request.headers['Authorization']&.split(' ', 2)&.last end - - def cors_allowed_methods - CORS_ALLOWED_METHODS[resource] - end - - def resource - request.path.gsub(%r{/\d+$}, '/*') - end - - def set_cors_headers - return unless allow_cors_request? - - response.set_header('Access-Control-Allow-Origin', Gitlab::CurrentSettings.jira_connect_proxy_url) - response.set_header('Access-Control-Allow-Methods', cors_allowed_methods.join(', ')) - end - - def allow_cors_request? - return false if cors_allowed_methods.nil? - - !Gitlab.com? && Gitlab::CurrentSettings.jira_connect_proxy_url.present? - end end diff --git a/app/controllers/jira_connect/cors_preflight_checks_controller.rb b/app/controllers/jira_connect/cors_preflight_checks_controller.rb deleted file mode 100644 index 3f30c1e04df..00000000000 --- a/app/controllers/jira_connect/cors_preflight_checks_controller.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module JiraConnect - class CorsPreflightChecksController < ApplicationController - feature_category :integrations - - skip_before_action :verify_atlassian_jwt! - before_action :set_cors_headers - - def index - return render_404 unless allow_cors_request? - - render plain: '', content_type: 'text/plain' - end - end -end diff --git a/app/controllers/jira_connect/events_controller.rb b/app/controllers/jira_connect/events_controller.rb index 394fdc9b2f6..fa1e1f505eb 100644 --- a/app/controllers/jira_connect/events_controller.rb +++ b/app/controllers/jira_connect/events_controller.rb @@ -31,7 +31,10 @@ class JiraConnect::EventsController < JiraConnect::ApplicationController end def update_installation - current_jira_installation.update(update_params) + JiraConnectInstallations::UpdateService.execute( + current_jira_installation, + update_params + ).success? end def create_params @@ -56,7 +59,7 @@ class JiraConnect::EventsController < JiraConnect::ApplicationController def jwt_verification_claims { - aud: jira_connect_base_url(protocol: 'https'), + aud: Gitlab.config.jira_connect.enforce_jira_base_url_https ? jira_connect_base_url(protocol: 'https') : jira_connect_base_url, iss: transformed_params[:client_key], qsh: Atlassian::Jwt.create_query_string_hash(request.url, request.method, jira_connect_base_url) } diff --git a/app/controllers/jira_connect/installations_controller.rb b/app/controllers/jira_connect/installations_controller.rb index 401bc4f9c87..44dbf90f5fb 100644 --- a/app/controllers/jira_connect/installations_controller.rb +++ b/app/controllers/jira_connect/installations_controller.rb @@ -6,11 +6,12 @@ class JiraConnect::InstallationsController < JiraConnect::ApplicationController end def update - if current_jira_installation.update(installation_params) + result = update_installation + if result.success? render json: installation_json(current_jira_installation) else render( - json: { errors: current_jira_installation.errors }, + json: { errors: result.message }, status: :unprocessable_entity ) end @@ -18,6 +19,13 @@ class JiraConnect::InstallationsController < JiraConnect::ApplicationController private + def update_installation + JiraConnectInstallations::UpdateService.execute( + current_jira_installation, + installation_params + ) + end + def installation_json(installation) { gitlab_com: installation.instance_url.blank?, diff --git a/app/controllers/jira_connect/oauth_application_ids_controller.rb b/app/controllers/jira_connect/oauth_application_ids_controller.rb index 3e788e2282e..de520337af3 100644 --- a/app/controllers/jira_connect/oauth_application_ids_controller.rb +++ b/app/controllers/jira_connect/oauth_application_ids_controller.rb @@ -5,7 +5,6 @@ module JiraConnect feature_category :integrations skip_before_action :verify_atlassian_jwt! - before_action :set_cors_headers def show if show_application_id? @@ -20,7 +19,7 @@ module JiraConnect def show_application_id? return if Gitlab.com? - Feature.enabled?(:jira_connect_oauth_self_managed) && jira_connect_application_key.present? + jira_connect_application_key.present? end def jira_connect_application_key diff --git a/app/controllers/jira_connect/public_keys_controller.rb b/app/controllers/jira_connect/public_keys_controller.rb index b3144993edb..09003f8478f 100644 --- a/app/controllers/jira_connect/public_keys_controller.rb +++ b/app/controllers/jira_connect/public_keys_controller.rb @@ -10,7 +10,9 @@ module JiraConnect skip_before_action :authenticate_user! def show - return render_404 if Feature.disabled?(:jira_connect_oauth_self_managed) || !Gitlab.com? + if Feature.disabled?(:jira_connect_oauth_self_managed) || !Gitlab.config.jira_connect.enable_public_keys_storage + return render_404 + end render plain: public_key.key end diff --git a/app/controllers/jira_connect/subscriptions_controller.rb b/app/controllers/jira_connect/subscriptions_controller.rb index 9a732cadd94..ff7477a94d6 100644 --- a/app/controllers/jira_connect/subscriptions_controller.rb +++ b/app/controllers/jira_connect/subscriptions_controller.rb @@ -1,19 +1,20 @@ # frozen_string_literal: true class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController + ALLOWED_IFRAME_ANCESTORS = [:self, 'https://*.atlassian.net', 'https://*.jira.com'].freeze layout 'jira_connect' content_security_policy do |p| next if p.directives.blank? # rubocop: disable Lint/PercentStringArray - script_src_values = Array.wrap(p.directives['script-src']) | %w('self' https://connect-cdn.atl-paas.net) - style_src_values = Array.wrap(p.directives['style-src']) | %w('self' 'unsafe-inline') + script_src_values = Array.wrap(p.directives['script-src']) | %w['self' https://connect-cdn.atl-paas.net] + style_src_values = Array.wrap(p.directives['style-src']) | %w['self' 'unsafe-inline'] # rubocop: enable Lint/PercentStringArray # *.jira.com is needed for some legacy Jira Cloud instances, new ones will use *.atlassian.net # https://support.atlassian.com/organization-administration/docs/ip-addresses-and-domains-for-atlassian-cloud-products/ - p.frame_ancestors :self, 'https://*.atlassian.net', 'https://*.jira.com' + p.frame_ancestors(*(ALLOWED_IFRAME_ANCESTORS + Gitlab.config.jira_connect.additional_iframe_ancestors)) p.script_src(*script_src_values) p.style_src(*style_src_values) end @@ -27,7 +28,6 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController before_action :verify_qsh_claim!, only: :index before_action :allow_self_managed_content_security_policy, only: :index before_action :authenticate_user!, only: :create - before_action :set_cors_headers def index @subscriptions = current_jira_installation.subscriptions.preload_namespace_route @@ -65,8 +65,6 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController private def allow_self_managed_content_security_policy - return unless Feature.enabled?(:jira_connect_oauth_self_managed_setting) - return unless current_jira_installation.instance_url? request.content_security_policy.directives['connect-src'] ||= [] diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index f3f0ddd968a..8650b6cbc6f 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -119,6 +119,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController identity_linker ||= auth_module::IdentityLinker.new(current_user, oauth, session) link_identity(identity_linker) + set_remember_me(current_user) if identity_linker.changed? redirect_identity_linked @@ -169,6 +170,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController # available in the logs for this request. Gitlab::ApplicationContext.push(user: user) log_audit_event(user, with: oauth['provider']) + Gitlab::Tracking.event(self.class.name, "#{oauth['provider']}_sso", user: user) if new_user set_remember_me(user) diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index 1216353be36..38cdb16c350 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -58,8 +58,8 @@ class PasswordsController < Devise::PasswordsController def check_password_authentication_available if resource return if resource.allow_password_authentication? - else - return if Gitlab::CurrentSettings.password_authentication_enabled? + elsif Gitlab::CurrentSettings.password_authentication_enabled? + return end redirect_to after_sending_reset_password_instructions_path_for(resource_name), diff --git a/app/controllers/profiles/keys_controller.rb b/app/controllers/profiles/keys_controller.rb index 90d5f945d78..39e8f6c500d 100644 --- a/app/controllers/profiles/keys_controller.rb +++ b/app/controllers/profiles/keys_controller.rb @@ -37,6 +37,6 @@ class Profiles::KeysController < Profiles::ApplicationController private def key_params - params.require(:key).permit(:title, :key, :expires_at) + params.require(:key).permit(:title, :key, :usage_type, :expires_at) end end diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index a57c87bf691..974e7104c07 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -57,7 +57,8 @@ class Profiles::PreferencesController < Profiles::ApplicationController :render_whitespace_in_code, :markdown_surround_selection, :markdown_automatic_lists, - :use_legacy_web_ide + :use_legacy_web_ide, + :use_new_navigation ] end end diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index 0933f2bb7ea..03b7cc9f892 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -97,7 +97,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController def skip if two_factor_grace_period_expired? - redirect_to new_profile_two_factor_auth_path, alert: s_('Cannot skip two factor authentication setup') + redirect_to new_profile_two_factor_auth_path, alert: _('Cannot skip two factor authentication setup') else session[:skip_two_factor] = current_user.otp_grace_period_started_at + two_factor_grace_period.hours redirect_to root_path @@ -186,9 +186,9 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController def u2f_registrations current_user.u2f_registrations.map do |u2f_registration| { - name: u2f_registration.name, - created_at: u2f_registration.created_at, - delete_path: profile_u2f_registration_path(u2f_registration) + name: u2f_registration.name, + created_at: u2f_registration.created_at, + delete_path: profile_u2f_registration_path(u2f_registration) } end end @@ -196,9 +196,9 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController def webauthn_registrations current_user.webauthn_registrations.map do |webauthn_registration| { - name: webauthn_registration.name, - created_at: webauthn_registration.created_at, - delete_path: profile_webauthn_registration_path(webauthn_registration) + name: webauthn_registration.name, + created_at: webauthn_registration.created_at, + delete_path: profile_webauthn_registration_path(webauthn_registration) } end end @@ -216,7 +216,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController group_links = groups.map { |group| view_context.link_to group.full_name, group_path(group) }.to_sentence leave_group_links = groups.map { |group| view_context.link_to (s_("leave %{group_name}") % { group_name: group.full_name }), leave_group_members_path(group), remote: false, method: :delete }.to_sentence - s_(%{The group settings for %{group_links} require you to enable Two-Factor Authentication for your account. You can %{leave_group_links}.}) + s_(%(The group settings for %{group_links} require you to enable Two-Factor Authentication for your account. You can %{leave_group_links}.)) .html_safe % { group_links: group_links.html_safe, leave_group_links: leave_group_links.html_safe } end diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb index 7755effe1da..ef20c71cd77 100644 --- a/app/controllers/projects/autocomplete_sources_controller.rb +++ b/app/controllers/projects/autocomplete_sources_controller.rb @@ -7,7 +7,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController feature_category :team_planning, [:issues, :labels, :milestones, :commands, :contacts] feature_category :code_review, [:merge_requests] feature_category :users, [:members] - feature_category :snippets, [:snippets] + feature_category :source_code_management, [:snippets] urgency :low, [:merge_requests, :members] urgency :low, [:issues, :labels, :milestones, :commands, :contacts] diff --git a/app/controllers/projects/badges_controller.rb b/app/controllers/projects/badges_controller.rb index 42bd87e1c01..dbbffc4c283 100644 --- a/app/controllers/projects/badges_controller.rb +++ b/app/controllers/projects/badges_controller.rb @@ -13,10 +13,10 @@ class Projects::BadgesController < Projects::ApplicationController def pipeline pipeline_status = Gitlab::Ci::Badge::Pipeline::Status .new(project, params[:ref], opts: { - ignore_skipped: params[:ignore_skipped], - key_text: params[:key_text], - key_width: params[:key_width] - }) + ignore_skipped: params[:ignore_skipped], + key_text: params[:key_text], + key_width: params[:key_width] + }) render_badge pipeline_status end @@ -24,13 +24,13 @@ class Projects::BadgesController < Projects::ApplicationController def coverage coverage_report = Gitlab::Ci::Badge::Coverage::Report .new(project, params[:ref], opts: { - job: params[:job], - key_text: params[:key_text], - key_width: params[:key_width], - min_good: params[:min_good], - min_acceptable: params[:min_acceptable], - min_medium: params[:min_medium] - }) + job: params[:job], + key_text: params[:key_text], + key_width: params[:key_width], + min_good: params[:min_good], + min_acceptable: params[:min_acceptable], + min_medium: params[:min_medium] + }) render_badge coverage_report end @@ -38,10 +38,10 @@ class Projects::BadgesController < Projects::ApplicationController def release latest_release = Gitlab::Ci::Badge::Release::LatestRelease .new(project, current_user, opts: { - key_text: params[:key_text], - key_width: params[:key_width], - order_by: params[:order_by] - }) + key_text: params[:key_text], + key_width: params[:key_width], + order_by: params[:order_by] + }) render_badge latest_release end diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb index 01ed5473b41..cfff281604e 100644 --- a/app/controllers/projects/blame_controller.rb +++ b/app/controllers/projects/blame_controller.rb @@ -7,7 +7,7 @@ class Projects::BlameController < Projects::ApplicationController before_action :require_non_empty_project before_action :assign_ref_vars - before_action :authorize_download_code! + before_action :authorize_read_code! feature_category :source_code_management urgency :low, [:show] diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index f5188e28b81..4eda76f4f21 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -18,7 +18,8 @@ class Projects::BlobController < Projects::ApplicationController around_action :allow_gitaly_ref_name_caching, only: [:show] before_action :require_non_empty_project, except: [:new, :create] - before_action :authorize_download_code! + before_action :authorize_download_code!, except: [:show] + before_action :authorize_read_code!, only: [:show] # We need to assign the blob vars before `authorize_edit_tree!` so we can # validate access to a specific ref. diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 27969cb1a75..7b01e4db42a 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -6,7 +6,7 @@ class Projects::BranchesController < Projects::ApplicationController # Authorize before_action :require_non_empty_project, except: :create - before_action :authorize_download_code! + before_action :authorize_read_code! before_action :authorize_push_code!, only: [:new, :create, :destroy, :destroy_all_merged] # Support legacy URLs diff --git a/app/controllers/projects/ci/daily_build_group_report_results_controller.rb b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb index b2b5e096105..37138afc719 100644 --- a/app/controllers/projects/ci/daily_build_group_report_results_controller.rb +++ b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb @@ -25,7 +25,7 @@ class Projects::Ci::DailyBuildGroupReportResultsController < Projects::Applicati { date: 'date', group_name: 'group_name', - param_type => -> (record) { record.data[param_type] } + param_type => ->(record) { record.data[param_type] } } ).render end diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb index 30d001d0ac5..b781365b3c3 100644 --- a/app/controllers/projects/clusters_controller.rb +++ b/app/controllers/projects/clusters_controller.rb @@ -5,7 +5,6 @@ class Projects::ClustersController < Clusters::ClustersController before_action :repository before_action do - push_frontend_feature_flag(:prometheus_computed_alerts) push_frontend_feature_flag(:show_gitlab_agent_feedback, type: :ops) end diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index 870320a79d9..583b572d4b1 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -12,7 +12,7 @@ class Projects::CommitController < Projects::ApplicationController # Authorize before_action :require_non_empty_project - before_action :authorize_download_code! + before_action :authorize_read_code! before_action :authorize_read_pipeline!, only: [:pipelines] before_action :commit before_action :define_commit_vars, only: [:show, :diff_for_path, :diff_files, :pipelines, :merge_requests] diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb index f4125fd0a15..c006d56ae81 100644 --- a/app/controllers/projects/commits_controller.rb +++ b/app/controllers/projects/commits_controller.rb @@ -12,7 +12,7 @@ class Projects::CommitsController < Projects::ApplicationController around_action :allow_gitaly_ref_name_caching before_action :require_non_empty_project before_action :assign_ref_vars, except: :commits_root - before_action :authorize_download_code! + before_action :authorize_read_code! before_action :validate_ref!, except: :commits_root before_action :set_commits, except: :commits_root @@ -28,6 +28,8 @@ class Projects::CommitsController < Projects::ApplicationController @merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened .find_by(source_project: @project, source_branch: @ref, target_branch: @repository.root_ref) + @ref_type = ref_type + respond_to do |format| format.html format.atom { render layout: 'xml' } @@ -73,18 +75,20 @@ class Projects::CommitsController < Projects::ApplicationController search = permitted_params[:search] author = permitted_params[:author] + # fully_qualified_ref is available in some situations when the use_ref_type_parameter FF is enabled + ref = @fully_qualified_ref || @ref @commits = if search.present? - @repository.find_commits_by_message(search, @ref, @path, @limit, @offset) + @repository.find_commits_by_message(search, ref, @path, @limit, @offset) elsif author.present? - @repository.commits(@ref, author: author, path: @path, limit: @limit, offset: @offset) + @repository.commits(ref, author: author, path: @path, limit: @limit, offset: @offset) else - @repository.commits(@ref, path: @path, limit: @limit, offset: @offset) + @repository.commits(ref, path: @path, limit: @limit, offset: @offset) end @commits.each(&:lazy_author) # preload authors - @commits = @commits.with_markdown_cache.with_latest_pipeline(@ref) + @commits = @commits.with_markdown_cache.with_latest_pipeline(ref) @commits = set_commits_for_rendering(@commits) end diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb index 61308f24412..266edd506d5 100644 --- a/app/controllers/projects/compare_controller.rb +++ b/app/controllers/projects/compare_controller.rb @@ -10,7 +10,7 @@ class Projects::CompareController < Projects::ApplicationController # Authorize before_action :require_non_empty_project - before_action :authorize_download_code! + before_action :authorize_read_code! # Defining ivars before_action :define_diffs, only: [:show, :diff_for_path] before_action :define_environment, only: [:show] @@ -95,7 +95,7 @@ class Projects::CompareController < Projects::ApplicationController target_project = target_projects(source_project).find_by_id(compare_params[:from_project_id]) # Just ignore the field if it points at a non-existent or hidden project - next source_project unless target_project && can?(current_user, :download_code, target_project) + next source_project unless target_project && can?(current_user, :read_code, target_project) target_project end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 67f2f85ce65..537fd3854c4 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -14,11 +14,11 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do authorize_metrics_dashboard! - - push_frontend_feature_flag(:prometheus_computed_alerts) - push_frontend_feature_flag(:disable_metric_dashboard_refresh_rate) end + before_action only: [:show] do + push_frontend_feature_flag(:environment_details_vue, @project) + end before_action :authorize_read_environment!, except: [:metrics, :additional_metrics, :metrics_dashboard, :metrics_redirect] before_action :authorize_create_environment!, only: [:new, :create] before_action :authorize_stop_environment!, only: [:stop] diff --git a/app/controllers/projects/find_file_controller.rb b/app/controllers/projects/find_file_controller.rb index c6bc115e737..b5099d555ae 100644 --- a/app/controllers/projects/find_file_controller.rb +++ b/app/controllers/projects/find_file_controller.rb @@ -8,7 +8,7 @@ class Projects::FindFileController < Projects::ApplicationController before_action :require_non_empty_project before_action :assign_ref_vars - before_action :authorize_download_code! + before_action :authorize_read_code! feature_category :source_code_management urgency :low, [:show, :list] diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index 3208a5076e7..ff3dc71b6cc 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -9,9 +9,9 @@ class Projects::ForksController < Projects::ApplicationController # Authorize before_action :disable_query_limiting, only: [:create] before_action :require_non_empty_project - before_action :authorize_download_code! + before_action :authorize_read_code! before_action :authenticate_user!, only: [:new, :create] - before_action :authorize_fork_project!, only: [:new, :create] + before_action :authorize_fork_project!, except: [:index] before_action :authorize_fork_namespace!, only: [:create] feature_category :source_code_management diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb index 6da70b5e157..d072381933a 100644 --- a/app/controllers/projects/graphs_controller.rb +++ b/app/controllers/projects/graphs_controller.rb @@ -21,11 +21,24 @@ class Projects::GraphsController < Projects::ApplicationController feature_category :continuous_integration, [:ci] urgency :low, [:ci] + MAX_COMMITS = 6000 + def show + @ref_type = ref_type + respond_to do |format| format.html format.json do - fetch_graph + commits = @project.repository.commits(ref, limit: MAX_COMMITS, skip_merges: true) + log = commits.map do |commit| + { + author_name: commit.author_name, + author_email: commit.author_email, + date: commit.committed_date.strftime("%Y-%m-%d") + } + end + + render json: Gitlab::Json.dump(log) end end end @@ -50,9 +63,13 @@ class Projects::GraphsController < Projects::ApplicationController private + def ref + @fully_qualified_ref || @ref + end + def get_commits @commits_limit = 2000 - @commits = @project.repository.commits(@ref, limit: @commits_limit, skip_merges: true) + @commits = @project.repository.commits(ref, limit: @commits_limit, skip_merges: true) @commits_graph = Gitlab::Graphs::Commits.new(@commits) @commits_per_week_days = @commits_graph.commits_per_week_days @commits_per_time = @commits_graph.commits_per_time @@ -76,7 +93,7 @@ class Projects::GraphsController < Projects::ApplicationController base_params: { start_date: date_today - report_window, end_date: date_today, - ref_path: @project.repository.expand_ref(@ref), + ref_path: @project.repository.expand_ref(ref), param_type: 'coverage' }, download_path: namespace_project_ci_daily_build_group_report_results_path( @@ -92,21 +109,6 @@ class Projects::GraphsController < Projects::ApplicationController } end - def fetch_graph - @commits = @project.repository.commits(@ref, limit: 6000, skip_merges: true) - @log = [] - - @commits.each do |commit| - @log << { - author_name: commit.author_name, - author_email: commit.author_email, - date: commit.committed_date.strftime("%Y-%m-%d") - } - end - - render json: Gitlab::Json.dump(@log) - end - def tracking_namespace_source project.namespace end diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb index 599505dcb6d..3842a88d15b 100644 --- a/app/controllers/projects/incidents_controller.rb +++ b/app/controllers/projects/incidents_controller.rb @@ -8,6 +8,7 @@ class Projects::IncidentsController < Projects::ApplicationController before_action :load_incident, only: [:show] before_action do push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?) + push_force_frontend_feature_flag(:work_items_mvc, @project&.work_items_mvc_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items_mvc_2, @project&.work_items_mvc_2_feature_flag_enabled?) end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index ee845cd001e..631e697dd2f 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -7,6 +7,7 @@ class Projects::IssuesController < Projects::ApplicationController include IssuableCollections include IssuesCalendar include RecordUserLastActivity + include ::Observability::ContentSecurityPolicy ISSUES_EXCEPT_ACTIONS = %i[index calendar new create bulk_update import_csv export_csv service_desk].freeze SET_ISSUABLES_INDEX_ONLY_ACTIONS = %i[index calendar service_desk].freeze @@ -19,7 +20,7 @@ class Projects::IssuesController < Projects::ApplicationController before_action :disable_query_limiting, only: [:create_merge_request, :move, :bulk_update] before_action :check_issues_available! before_action :issue, unless: ->(c) { ISSUES_EXCEPT_ACTIONS.include?(c.action_name.to_sym) } - before_action :redirect_if_task, unless: ->(c) { ISSUES_EXCEPT_ACTIONS.include?(c.action_name.to_sym) } + before_action :redirect_if_work_item, unless: ->(c) { ISSUES_EXCEPT_ACTIONS.include?(c.action_name.to_sym) } after_action :log_issue_show, only: :show @@ -37,7 +38,7 @@ class Projects::IssuesController < Projects::ApplicationController before_action :authorize_create_merge_request_from!, only: [:create_merge_request] before_action :authorize_import_issues!, only: [:import_csv] - before_action :authorize_download_code!, only: [:related_branches] + before_action :authorize_read_code!, only: [:related_branches] before_action do push_frontend_feature_flag(:preserve_unchanged_markdown, project) @@ -55,8 +56,10 @@ class Projects::IssuesController < Projects::ApplicationController before_action only: :show do push_frontend_feature_flag(:issue_assignees_widget, project) push_frontend_feature_flag(:work_items_mvc, project&.group) + push_force_frontend_feature_flag(:work_items_mvc, project&.work_items_mvc_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?) push_frontend_feature_flag(:epic_widget_edit_confirmation, project) + push_frontend_feature_flag(:use_iid_in_work_items_path, project) push_force_frontend_feature_flag(:work_items_create_from_markdown, project&.work_items_create_from_markdown_feature_flag_enabled?) end @@ -432,8 +435,8 @@ class Projects::IssuesController < Projects::ApplicationController # Overridden in EE def create_vulnerability_issue_feedback(issue); end - def redirect_if_task - return unless issue.task? + def redirect_if_work_item + return unless allowed_work_item? if Feature.enabled?(:use_iid_in_work_items_path, project.group) redirect_to project_work_items_path(project, issue.iid, params: request.query_parameters.merge(iid_path: true)) @@ -441,6 +444,10 @@ class Projects::IssuesController < Projects::ApplicationController redirect_to project_work_items_path(project, issue.id, params: request.query_parameters) end end + + def allowed_work_item? + issue.task? + end end Projects::IssuesController.prepend_mod_with('Projects::IssuesController') diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 557ac566733..c6d442a6f27 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -20,9 +20,6 @@ class Projects::JobsController < Projects::ApplicationController before_action :verify_proxy_request!, only: :proxy_websocket_authorize before_action :push_job_log_jump_to_failures, only: [:show] before_action :reject_if_build_artifacts_size_refreshing!, only: [:erase] - before_action do - push_frontend_feature_flag(:graphql_job_app, project, type: :development) - end layout 'project' diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index 93e2298ca98..cba0056ccd5 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -4,6 +4,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap include DiffForPath include DiffHelper include RendersCommits + include ::Observability::ContentSecurityPolicy skip_before_action :merge_request before_action :authorize_create_merge_request_from! @@ -19,6 +20,10 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap :branch_to ] + before_action do + push_frontend_feature_flag(:mr_compare_dropdowns, project) + end + def new define_new_vars end @@ -89,6 +94,14 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap render layout: false end + def target_projects + projects = MergeRequestTargetProjectFinder + .new(current_user: current_user, source_project: @project, project_feature: :repository) + .execute(include_routes: true).limit(20).search(params[:search]) + + render json: ProjectSerializer.new.represent(projects) + end + private def build_merge_request diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index c88dbc70ed5..83377f67723 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -60,17 +60,11 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic options[:merge_conflicts_in_diff] ] - if Feature.enabled?(:check_etags_diffs_batch_before_write_cache, merge_request.project) && !stale?(etag: [cache_context + diff_options_hash.fetch(:paths, []), diffs]) - return - end + return unless stale?(etag: [cache_context + diff_options_hash.fetch(:paths, []), diffs]) diffs.unfold_diff_files(unfoldable_positions) diffs.write_cache - if Feature.disabled?(:check_etags_diffs_batch_before_write_cache, merge_request.project) && !stale?(etag: [cache_context + diff_options_hash.fetch(:paths, []), diffs]) - return - end - render json: PaginatedDiffSerializer.new(current_user: current_user).represent(diffs, options) end # rubocop: enable Metrics/AbcSize diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 4ba79d43f27..3ab1f7d1d32 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -11,10 +11,11 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo include SourcegraphDecorator include DiffHelper include Gitlab::Cache::Helpers + include ::Observability::ContentSecurityPolicy prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } skip_before_action :merge_request, only: [:index, :bulk_update, :export_csv] - before_action :apply_diff_view_cookie!, only: [:show] + before_action :apply_diff_view_cookie!, only: [:show, :diffs] before_action :disable_query_limiting, only: [:assign_related_issues, :update] before_action :authorize_update_issuable!, only: [:close, :edit, :update, :remove_wip, :sort] before_action :authorize_read_actual_head_pipeline!, only: [ @@ -30,7 +31,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action :authenticate_user!, only: [:assign_related_issues] before_action :check_user_can_push_to_source_branch!, only: [:rebase] - before_action only: [:show] do + before_action only: [:show, :diffs] do push_frontend_feature_flag(:core_security_mr_widget_counts, project) push_frontend_feature_flag(:issue_assignees_widget, @project) push_frontend_feature_flag(:refactor_security_extension, @project) @@ -40,21 +41,22 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:mr_review_submit_comment, project) push_frontend_feature_flag(:mr_experience_survey, project) push_frontend_feature_flag(:realtime_reviewers, project) + push_frontend_feature_flag(:realtime_mr_status_change, project) end before_action do push_frontend_feature_flag(:permit_all_shared_groups_for_approval, @project) end - around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions] + around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :diffs, :discussions] - after_action :log_merge_request_show, only: [:show] + after_action :log_merge_request_show, only: [:show, :diffs] feature_category :code_review, [ :assign_related_issues, :bulk_update, :cancel_auto_merge, :commit_change_content, :commits, :context_commits, :destroy, :discussions, :edit, :index, :merge, :rebase, :remove_wip, - :show, :toggle_award_emoji, :toggle_subscription, :update + :show, :diffs, :toggle_award_emoji, :toggle_subscription, :update ] feature_category :code_testing, [:test_reports, :coverage_reports] @@ -67,6 +69,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo urgency :low, [ :index, :show, + :diffs, :commits, :bulk_update, :edit, @@ -100,74 +103,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end end - # rubocop:disable Metrics/AbcSize def show - close_merge_request_if_no_source_project - @merge_request.check_mergeability(async: true) - - respond_to do |format| - format.html do - # use next to appease Rubocop - next render('invalid') if target_branch_missing? - - preload_assignees_for_render(@merge_request) - - # Build a note object for comment form - @note = @project.notes.new(noteable: @merge_request) - - @noteable = @merge_request - @commits_count = @merge_request.commits_count + @merge_request.context_commits_count - @diffs_count = get_diffs_count - @issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar') - @current_user_data = Gitlab::Json.dump(UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestCurrentUserEntity)) - @show_whitespace_default = current_user.nil? || current_user.show_whitespace_in_diffs - @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? - @update_current_user_path = expose_path(api_v4_user_preferences_path) - @endpoint_metadata_url = endpoint_metadata_url(@project, @merge_request) - @endpoint_diff_batch_url = endpoint_diff_batch_url(@project, @merge_request) - - set_pipeline_variables - - @number_of_pipelines = @pipelines.size - - render - end - - format.json do - Gitlab::PollingInterval.set_header(response, interval: 10_000) - - if params[:serializer] == 'sidebar_extras' - cache_context = [ - params[:serializer], - current_user&.cache_key, - @merge_request.merge_request_assignees.map(&:cache_key), - @merge_request.merge_request_reviewers.map(&:cache_key) - ] - - render_cached(@merge_request, - with: serializer, - cache_context: -> (_) { [Digest::SHA256.hexdigest(cache_context.to_s)] }, - serializer: params[:serializer]) - else - render json: serializer.represent(@merge_request, serializer: params[:serializer]) - end - end - - format.patch do - break render_404 unless @merge_request.diff_refs - - send_git_patch @project.repository, @merge_request.diff_refs - end - - format.diff do - break render_404 unless @merge_request.diff_refs + show_merge_request + end - send_git_diff @project.repository, @merge_request.diff_refs - end - end + def diffs + show_merge_request end - # rubocop:enable Metrics/AbcSize def commits # Get context commits from repository @@ -412,6 +354,77 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo private + def show_merge_request + close_merge_request_if_no_source_project + @merge_request.check_mergeability(async: true) + + respond_to do |format| + format.html do + # use next to appease Rubocop + next render('invalid') if target_branch_missing? + + render_html_page + end + + format.json do + Gitlab::PollingInterval.set_header(response, interval: 10_000) + + if params[:serializer] == 'sidebar_extras' + cache_context = [ + params[:serializer], + current_user&.cache_key, + @merge_request.merge_request_assignees.map(&:cache_key), + @merge_request.merge_request_reviewers.map(&:cache_key) + ] + + render_cached(@merge_request, + with: serializer, + cache_context: ->(_) { [Digest::SHA256.hexdigest(cache_context.to_s)] }, + serializer: params[:serializer]) + else + render json: serializer.represent(@merge_request, serializer: params[:serializer]) + end + end + + format.patch do + break render_404 unless @merge_request.diff_refs + + send_git_patch @project.repository, @merge_request.diff_refs + end + + format.diff do + break render_404 unless @merge_request.diff_refs + + send_git_diff @project.repository, @merge_request.diff_refs + end + end + end + + def render_html_page + preload_assignees_for_render(@merge_request) + + # Build a note object for comment form + @note = @project.notes.new(noteable: @merge_request) + + @noteable = @merge_request + @commits_count = @merge_request.commits_count + @merge_request.context_commits_count + @diffs_count = get_diffs_count + @issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar') + @current_user_data = Gitlab::Json.dump(UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestCurrentUserEntity)) + @show_whitespace_default = current_user.nil? || current_user.show_whitespace_in_diffs + @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? + @update_current_user_path = expose_path(api_v4_user_preferences_path) + @endpoint_metadata_url = endpoint_metadata_url(@project, @merge_request) + @endpoint_diff_batch_url = endpoint_diff_batch_url(@project, @merge_request) + + set_pipeline_variables + + @number_of_pipelines = @pipelines.size + + render + end + def get_diffs_count if show_only_context_commits? @merge_request.context_commits_diff.raw_diffs.size diff --git a/app/controllers/projects/metrics_dashboard_controller.rb b/app/controllers/projects/metrics_dashboard_controller.rb index b78ee6ca917..08757d11912 100644 --- a/app/controllers/projects/metrics_dashboard_controller.rb +++ b/app/controllers/projects/metrics_dashboard_controller.rb @@ -9,10 +9,6 @@ module Projects include Gitlab::Utils::StrongMemoize before_action :authorize_metrics_dashboard! - before_action do - push_frontend_feature_flag(:prometheus_computed_alerts) - push_frontend_feature_flag(:disable_metric_dashboard_refresh_rate) - end feature_category :metrics urgency :low diff --git a/app/controllers/projects/ml/candidates_controller.rb b/app/controllers/projects/ml/candidates_controller.rb new file mode 100644 index 00000000000..b702edb858e --- /dev/null +++ b/app/controllers/projects/ml/candidates_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Projects + module Ml + class CandidatesController < ApplicationController + before_action :check_feature_flag + + feature_category :mlops + + def show + @candidate = ::Ml::Candidate.with_project_id_and_iid(@project.id, params['iid']) + + render_404 unless @candidate.present? + end + + private + + def check_feature_flag + render_404 unless Feature.enabled?(:ml_experiment_tracking, @project) + end + end + end +end diff --git a/app/controllers/projects/ml/experiments_controller.rb b/app/controllers/projects/ml/experiments_controller.rb index 749586791ac..c82a959d612 100644 --- a/app/controllers/projects/ml/experiments_controller.rb +++ b/app/controllers/projects/ml/experiments_controller.rb @@ -3,7 +3,6 @@ module Projects module Ml class ExperimentsController < ::Projects::ApplicationController - include Projects::Ml::ExperimentsHelper before_action :check_feature_flag feature_category :mlops diff --git a/app/controllers/projects/network_controller.rb b/app/controllers/projects/network_controller.rb index 84ac9fb01fd..aa0838752e2 100644 --- a/app/controllers/projects/network_controller.rb +++ b/app/controllers/projects/network_controller.rb @@ -6,7 +6,7 @@ class Projects::NetworkController < Projects::ApplicationController before_action :require_non_empty_project before_action :assign_ref_vars - before_action :authorize_download_code! + before_action :authorize_read_code! before_action :assign_options before_action :assign_commit @@ -14,7 +14,13 @@ class Projects::NetworkController < Projects::ApplicationController urgency :low, [:show] def show - @url = project_network_path(@project, @ref, @options.merge(format: :json)) + @url = if Feature.enabled?(:use_ref_type_parameter, @project) + project_network_path(@project, @ref, @options.merge(format: :json, ref_type: ref_type)) + else + project_network_path(@project, @ref, @options.merge(format: :json)) + end + + @ref_type = ref_type @commit_url = project_commit_path(@project, 'ae45ca32').gsub("ae45ca32", "%s") respond_to do |format| diff --git a/app/controllers/projects/performance_monitoring/dashboards_controller.rb b/app/controllers/projects/performance_monitoring/dashboards_controller.rb index 8acbc17aef3..d043f8d0b9f 100644 --- a/app/controllers/projects/performance_monitoring/dashboards_controller.rb +++ b/app/controllers/projects/performance_monitoring/dashboards_controller.rb @@ -70,7 +70,7 @@ module Projects end def validate_required_params! - params.require(%i(branch file_name dashboard commit_message)) + params.require(%i[branch file_name dashboard commit_message]) end def redirect_safe_branch_name @@ -78,7 +78,7 @@ module Projects end def dashboard_params - params.permit(%i(branch file_name dashboard commit_message)).to_h + params.permit(%i[branch file_name dashboard commit_message]).to_h end def file_content_params diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 7d1a75ae449..db77127cb0a 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -24,11 +24,6 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :ensure_pipeline, only: [:show, :downloadable_artifacts] before_action :reject_if_build_artifacts_size_refreshing!, only: [:destroy] - before_action do - push_frontend_feature_flag(:pipeline_tabs_vue, @project) - push_frontend_feature_flag(:run_pipeline_graphql, @project) - end - # Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596 before_action :redirect_for_legacy_scope_filter, only: [:index], if: -> { request.format.html? } diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb index 8c70ef446a2..baa4607dcb6 100644 --- a/app/controllers/projects/protected_branches_controller.rb +++ b/app/controllers/projects/protected_branches_controller.rb @@ -1,6 +1,12 @@ # frozen_string_literal: true class Projects::ProtectedBranchesController < Projects::ProtectedRefsController + def show + super + + render 'protected_branches/show' + end + protected def project_refs diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb index 9707b70f26f..924de0ee7ea 100644 --- a/app/controllers/projects/raw_controller.rb +++ b/app/controllers/projects/raw_controller.rb @@ -12,7 +12,7 @@ class Projects::RawController < Projects::ApplicationController before_action :set_ref_and_path before_action :require_non_empty_project - before_action :authorize_download_code! + before_action :authorize_read_code! before_action :check_show_rate_limit!, only: [:show], unless: :external_storage_request? before_action :redirect_to_external_storage, only: :show, if: :static_objects_external_storage_enabled? @@ -21,7 +21,7 @@ class Projects::RawController < Projects::ApplicationController def show @blob = @repository.blob_at(@ref, @path, limit: Gitlab::Git::Blob::LFS_POINTER_MAX_SIZE) - send_blob(@repository, @blob, inline: (params[:inline] != 'false'), allow_caching: Guest.can?(:download_code, @project)) + send_blob(@repository, @blob, inline: (params[:inline] != 'false'), allow_caching: Guest.can?(:read_code, @project)) end private diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb index 72af3280a39..8ac6d872aae 100644 --- a/app/controllers/projects/refs_controller.rb +++ b/app/controllers/projects/refs_controller.rb @@ -9,7 +9,7 @@ class Projects::RefsController < Projects::ApplicationController before_action :require_non_empty_project before_action :validate_ref_id before_action :assign_ref_vars - before_action :authorize_download_code! + before_action :authorize_read_code! feature_category :source_code_management urgency :low, [:switch, :logs_tree] @@ -24,9 +24,17 @@ class Projects::RefsController < Projects::ApplicationController when "blob" project_blob_path(@project, @id) when "graph" - project_network_path(@project, @id, @options) + if Feature.enabled?(:use_ref_type_parameter, @project) + project_network_path(@project, @id, ref_type: ref_type) + else + project_network_path(@project, @id, @options) + end when "graphs" - project_graph_path(@project, @id) + if Feature.enabled?(:use_ref_type_parameter, @project) + project_graph_path(@project, @id, ref_type: ref_type) + else + project_graph_path(@project, @id) + end when "find_file" project_find_file_path(@project, @id) when "graphs_commits" @@ -34,7 +42,11 @@ class Projects::RefsController < Projects::ApplicationController when "badges" project_settings_ci_cd_path(@project, ref: @id) else - project_commits_path(@project, @id) + if Feature.enabled?(:use_ref_type_parameter, @project) + project_commits_path(@project, @id, ref_type: ref_type) + else + project_commits_path(@project, @id) + end end redirect_to new_path diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb index ffe95bf4fee..6c663c4694a 100644 --- a/app/controllers/projects/registry/repositories_controller.rb +++ b/app/controllers/projects/registry/repositories_controller.rb @@ -23,10 +23,6 @@ module Projects def destroy image.delete_scheduled! - unless Feature.enabled?(:container_registry_delete_repository_with_cron_worker) - DeleteContainerRepositoryWorker.perform_async(current_user.id, image.id) # rubocop:disable CodeReuse/Worker - end - track_package_event(:delete_repository, :container) respond_to do |format| diff --git a/app/controllers/projects/runner_projects_controller.rb b/app/controllers/projects/runner_projects_controller.rb index 5946c43b134..6f896244acb 100644 --- a/app/controllers/projects/runner_projects_controller.rb +++ b/app/controllers/projects/runner_projects_controller.rb @@ -11,7 +11,7 @@ class Projects::RunnerProjectsController < Projects::ApplicationController def create @runner = Ci::Runner.find(params[:runner_project][:runner_id]) - return head(403) unless can?(current_user, :assign_runner, @runner) + return head(:forbidden) unless can?(current_user, :assign_runner, @runner) path = project_runners_path(project) diff --git a/app/controllers/projects/service_desk_controller.rb b/app/controllers/projects/service_desk_controller.rb index aa0e70121df..8f576b8d72b 100644 --- a/app/controllers/projects/service_desk_controller.rb +++ b/app/controllers/projects/service_desk_controller.rb @@ -29,7 +29,7 @@ class Projects::ServiceDeskController < Projects::ApplicationController end def allowed_update_attributes - %i(issue_template_key outgoing_name project_key) + %i[issue_template_key outgoing_name project_key] end def service_desk_attributes diff --git a/app/controllers/projects/service_ping_controller.rb b/app/controllers/projects/service_ping_controller.rb index 43c249afd8e..cfc322b47e7 100644 --- a/app/controllers/projects/service_ping_controller.rb +++ b/app/controllers/projects/service_ping_controller.rb @@ -10,7 +10,7 @@ class Projects::ServicePingController < Projects::ApplicationController Gitlab::UsageDataCounters::WebIdeCounter.increment_previews_count - head(200) + head(:ok) end def web_ide_clientside_preview_success @@ -20,12 +20,12 @@ class Projects::ServicePingController < Projects::ApplicationController Gitlab::UsageDataCounters::EditorUniqueCounter.track_live_preview_edit_action(author: current_user, project: project) - head(200) + head(:ok) end def web_ide_pipelines_count Gitlab::UsageDataCounters::WebIdeCounter.increment_pipelines_count - head(200) + head(:ok) end end diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index 8aef1c3d24d..cf07de4dc29 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -18,6 +18,9 @@ module Projects urgency :low def show + @entity = :project + @variable_limit = ::Plan.default.actual_limits.project_ci_variables + if Feature.enabled?(:ci_pipeline_triggers_settings_vue_ui, @project) triggers = ::Ci::TriggerSerializer.new.represent( @project.triggers, current_user: current_user, project: @project @@ -122,11 +125,13 @@ module Projects .page(params[:specific_page]).per(NUMBER_OF_RUNNERS_PER_PAGE) .with_tags - @shared_runners = ::Ci::Runner.instance_type.active.with_tags - - @shared_runners_count = @shared_runners.count(:all) + active_shared_runners = ::Ci::Runner.instance_type.active + @shared_runners_count = active_shared_runners.count + @shared_runners = active_shared_runners.page(params[:shared_runners_page]).per(NUMBER_OF_RUNNERS_PER_PAGE).with_tags - @group_runners = ::Ci::Runner.belonging_to_parent_group_of_project(@project.id).with_tags + parent_group_runners = ::Ci::Runner.belonging_to_parent_group_of_project(@project.id) + @group_runners_count = parent_group_runners.count + @group_runners = parent_group_runners.page(params[:group_runners_page]).per(NUMBER_OF_RUNNERS_PER_PAGE).with_tags end def define_ci_variables diff --git a/app/controllers/projects/settings/integrations_controller.rb b/app/controllers/projects/settings/integrations_controller.rb index 2bbcd9fe20c..16c1373df2b 100644 --- a/app/controllers/projects/settings/integrations_controller.rb +++ b/app/controllers/projects/settings/integrations_controller.rb @@ -79,7 +79,7 @@ module Projects return { error: true, message: _('Validations failed.'), - service_response: integration.errors.full_messages.join(','), + service_response: integration.errors.full_messages.join(', '), test_failed: false } end @@ -90,7 +90,7 @@ module Projects return { error: true, message: s_('Integrations|Connection failed. Check your integration settings.'), - service_response: result[:message].to_s, + service_response: result[:result].to_s, test_failed: true } end diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb index 90988645d3a..6d099aa8b3d 100644 --- a/app/controllers/projects/settings/repository_controller.rb +++ b/app/controllers/projects/settings/repository_controller.rb @@ -95,6 +95,14 @@ module Projects @protected_tags_count = @protected_tags.reduce(0) { |sum, tag| sum + tag.matching(@project.repository.tag_names).size } + if Feature.enabled?(:group_protected_branches) + @protected_group_branches = if @project.root_namespace.is_a?(Group) + @project.root_namespace.protected_branches.order(:name).page(params[:page]) + else + [] + end + end + load_gon_index end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/controllers/projects/snippets/application_controller.rb b/app/controllers/projects/snippets/application_controller.rb index 8ee12bf3795..b8faf464531 100644 --- a/app/controllers/projects/snippets/application_controller.rb +++ b/app/controllers/projects/snippets/application_controller.rb @@ -4,7 +4,7 @@ class Projects::Snippets::ApplicationController < Projects::ApplicationControlle include FindSnippet include SnippetAuthorizations - feature_category :snippets + feature_category :source_code_management private diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb index 847b1baca10..3c1735c728c 100644 --- a/app/controllers/projects/tags_controller.rb +++ b/app/controllers/projects/tags_controller.rb @@ -7,7 +7,7 @@ class Projects::TagsController < Projects::ApplicationController # Authorize before_action :require_non_empty_project - before_action :authorize_download_code! + before_action :authorize_read_code! before_action :authorize_admin_tag!, only: [:new, :create, :destroy] feature_category :source_code_management diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index fea2689db14..737a6290431 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -13,11 +13,10 @@ class Projects::TreeController < Projects::ApplicationController before_action :require_non_empty_project, except: [:new, :create] before_action :assign_ref_vars before_action :assign_dir_vars, only: [:create_dir] - before_action :authorize_download_code! + before_action :authorize_read_code! before_action :authorize_edit_tree!, only: [:create_dir] before_action do - push_frontend_feature_flag(:lazy_load_commits, @project) push_frontend_feature_flag(:highlight_js, @project) push_frontend_feature_flag(:file_line_blame, @project) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb index a8f062bd7c1..a83ccccbeae 100644 --- a/app/controllers/projects/variables_controller.rb +++ b/app/controllers/projects/variables_controller.rb @@ -47,6 +47,6 @@ class Projects::VariablesController < Projects::ApplicationController end def variable_params_attributes - %i[id variable_type key secret_value protected masked environment_scope _destroy] + %i[id variable_type key secret_value protected masked raw environment_scope _destroy] end end diff --git a/app/controllers/projects/work_items_controller.rb b/app/controllers/projects/work_items_controller.rb index a7e59a28fb7..a118c6986f7 100644 --- a/app/controllers/projects/work_items_controller.rb +++ b/app/controllers/projects/work_items_controller.rb @@ -3,6 +3,7 @@ class Projects::WorkItemsController < Projects::ApplicationController before_action do push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?) + push_force_frontend_feature_flag(:work_items_mvc, project&.work_items_mvc_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?) push_frontend_feature_flag(:use_iid_in_work_items_path, project) end @@ -10,3 +11,5 @@ class Projects::WorkItemsController < Projects::ApplicationController feature_category :team_planning urgency :low end + +Projects::WorkItemsController.prepend_mod diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index a5dacbf7f2f..886819fe778 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -26,7 +26,7 @@ class ProjectsController < Projects::ApplicationController before_action :verify_git_import_enabled, only: [:create] 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] + before_action :authorize_read_code!, only: [:refs] # Authorize before_action :authorize_admin_project!, only: [:edit, :update, :housekeeping, :download_export, :export, :remove_export, :generate_new_export] @@ -37,21 +37,17 @@ class ProjectsController < Projects::ApplicationController before_action :check_export_rate_limit!, only: [:export, :download_export, :generate_new_export] before_action do - push_frontend_feature_flag(:lazy_load_commits, @project) push_frontend_feature_flag(:highlight_js, @project) push_frontend_feature_flag(:file_line_blame, @project) push_frontend_feature_flag(:increase_page_size_exponentially, @project) push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks) push_licensed_feature(:security_orchestration_policies) if @project.present? && @project.licensed_feature_available?(:security_orchestration_policies) push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?) + push_force_frontend_feature_flag(:work_items_mvc, @project&.work_items_mvc_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items_mvc_2, @project&.work_items_mvc_2_feature_flag_enabled?) push_frontend_feature_flag(:package_registry_access_level) end - before_action only: :edit do - push_frontend_feature_flag(:split_operations_visibility_permissions, @project) - end - layout :determine_layout feature_category :projects, [ @@ -369,7 +365,7 @@ class ProjectsController < Projects::ApplicationController def render_landing_page Gitlab::Tracking.event('project_overview', 'render', user: current_user, project: @project.project) - if can?(current_user, :download_code, @project) + if can?(current_user, :read_code, @project) return render 'projects/no_repo' unless @project.repository_exists? render 'projects/empty' if @project.empty_repo? @@ -433,17 +429,11 @@ class ProjectsController < Projects::ApplicationController security_and_compliance_access_level container_registry_access_level releases_access_level - ] + operations_feature_attributes - end - - def operations_feature_attributes - if Feature.enabled?(:split_operations_visibility_permissions, project) - %i[ - environments_access_level feature_flags_access_level monitor_access_level infrastructure_access_level - ] - else - %i[operations_access_level] - end + environments_access_level + feature_flags_access_level + monitor_access_level + infrastructure_access_level + ] end def project_setting_attributes @@ -520,14 +510,6 @@ class ProjectsController < Projects::ApplicationController false end - def project_view_files? - if current_user - current_user.project_view == 'files' - else - project_view_files_allowed? - end - end - # Override extract_ref from ExtractsPath, which returns the branch and file path # for the blob/tree, which in this case is just the root of the default branch. # This way we avoid to access the repository.ref_names. @@ -540,10 +522,6 @@ class ProjectsController < Projects::ApplicationController project.repository.root_ref end - def project_view_files_allowed? - !project.empty_repo? && can?(current_user, :download_code, project) - end - def build_canonical_path(project) params[:namespace_id] = project.namespace.to_param params[:id] = project.to_param diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb index a49b82319da..4a42632a980 100644 --- a/app/controllers/registrations/welcome_controller.rb +++ b/app/controllers/registrations/welcome_controller.rb @@ -14,12 +14,16 @@ module Registrations def show return redirect_to path_for_signed_in_user(current_user) if completed_welcome_step? + + track_event('render') end def update result = ::Users::SignupService.new(current_user, update_params).execute if result[:status] == :success + track_event('successfully_submitted_form') + return redirect_to issues_dashboard_path(assignee_username: current_user.username) if show_tasks_to_be_done? return redirect_to update_success_path if show_signup_onboarding? @@ -86,6 +90,10 @@ module Registrations # overridden in EE def update_success_path end + + # overridden in EE + def track_event(category) + end end end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 995303a631a..11f9f1cf0c6 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -15,10 +15,10 @@ class RegistrationsController < Devise::RegistrationsController layout 'devise' prepend_before_action :check_captcha, only: :create + before_action :ensure_first_name_and_last_name_not_empty, only: :create before_action :ensure_destroy_prerequisites_met, only: [:destroy] before_action :init_preferred_language, only: :new before_action :load_recaptcha, only: :new - before_action :set_invite_params, only: :new before_action only: [:create] do check_rate_limit!(:user_sign_up, scope: request.ip) end @@ -32,11 +32,11 @@ class RegistrationsController < Devise::RegistrationsController def new @resource = build_resource + set_invite_params end def create - set_user_state - set_custom_confirmation_token + set_resource_fields super do |new_user| accept_pending_invitations if new_user.persisted? @@ -111,8 +111,11 @@ class RegistrationsController < Devise::RegistrationsController super end + # overridden by EE module def after_request_hook(user) - # overridden by EE module + return unless user.persisted? + + Gitlab::Tracking.event(self.class.name, 'successfully_submitted_form', user: user) end def after_sign_up_path_for(user) @@ -132,6 +135,7 @@ class RegistrationsController < Devise::RegistrationsController return identity_verification_redirect_path if custom_confirmation_enabled? + Gitlab::Tracking.event(self.class.name, 'render', user: resource) users_almost_there_path(email: resource.email) end @@ -172,6 +176,21 @@ class RegistrationsController < Devise::RegistrationsController render action: 'new' end + def ensure_first_name_and_last_name_not_empty + # The key here will be affected by feature flag 'arkose_labs_signup_challenge' + # When flag is disabled, the key will be 'user' because #check_captcha will remove 'new_' prefix + # When flag is enabled, #check_captcha will be skipped, so the key will have 'new_' prefix + first_name = params.dig(resource_name, :first_name) || params.dig("new_#{resource_name}", :first_name) + last_name = params.dig(resource_name, :last_name) || params.dig("new_#{resource_name}", :last_name) + + return if first_name.present? && last_name.present? + + resource.errors.add(_('First name'), _("cannot be blank")) if first_name.blank? + resource.errors.add(_('Last name'), _("cannot be blank")) if last_name.blank? + + render action: 'new' + end + def pending_approval? return false unless Gitlab::CurrentSettings.require_admin_approval_after_user_signup @@ -211,18 +230,22 @@ class RegistrationsController < Devise::RegistrationsController Gitlab::Recaptcha.load_configurations! end - def set_user_state + # overridden by EE module + def set_resource_fields return unless set_blocked_pending_approval? resource.state = User::BLOCKED_PENDING_APPROVAL_STATE end + # overridden by EE module 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]) + if resource.email.blank? && params[:invite_email].present? + resource.email = @invite_email = ActionController::Base.helpers.sanitize(params[:invite_email]) + end end def after_pending_invitations_hook @@ -251,10 +274,6 @@ class RegistrationsController < Devise::RegistrationsController # overridden by EE module end - def set_custom_confirmation_token - # overridden by EE module - end - def send_custom_confirmation_instructions # overridden by EE module end diff --git a/app/controllers/repositories/lfs_locks_api_controller.rb b/app/controllers/repositories/lfs_locks_api_controller.rb index f36126d67ff..ea858d63236 100644 --- a/app/controllers/repositories/lfs_locks_api_controller.rb +++ b/app/controllers/repositories/lfs_locks_api_controller.rb @@ -54,9 +54,9 @@ module Repositories def error_payload(message, custom_attrs = {}) custom_attrs.merge({ - message: message, - documentation_url: help_url - }) + message: message, + documentation_url: help_url + }) end def split_by_owner(locks) @@ -72,7 +72,7 @@ module Repositories end def upload_request? - %w(create unlock verify).include?(params[:action]) + %w[create unlock verify].include?(params[:action]) end def lfs_params diff --git a/app/controllers/repositories/lfs_storage_controller.rb b/app/controllers/repositories/lfs_storage_controller.rb index d54b51b463a..22f1a81b95b 100644 --- a/app/controllers/repositories/lfs_storage_controller.rb +++ b/app/controllers/repositories/lfs_storage_controller.rb @@ -49,7 +49,7 @@ module Repositories validate_uploaded_file! if store_file!(oid, size) - head 200, content_type: LfsRequest::CONTENT_TYPE + head :ok, content_type: LfsRequest::CONTENT_TYPE else render plain: 'Unprocessable entity', status: :unprocessable_entity end diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 5351e3e9e77..66968b34380 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -8,6 +8,7 @@ class SearchController < ApplicationController include SearchRateLimitable RESCUE_FROM_TIMEOUT_ACTIONS = [:count, :show, :autocomplete, :aggregations].freeze + CODE_SEARCH_LITERALS = %w[blob: extension: path: filename:].freeze track_custom_event :show, name: 'i_search_total', @@ -32,7 +33,10 @@ class SearchController < ApplicationController before_action only: :show do push_frontend_feature_flag(:search_page_vertical_nav, current_user) end - + before_action only: :show do + update_scope_for_code_search + end + before_action :elasticsearch_in_use, only: :show rescue_from ActiveRecord::QueryCanceled, with: :render_timeout layout 'search' @@ -43,6 +47,7 @@ class SearchController < ApplicationController def show @project = search_service.project @group = search_service.group + @search_service = Gitlab::View::Presenter::Factory.new(search_service, current_user: current_user).fabricate! return unless search_term_valid? @@ -51,15 +56,11 @@ class SearchController < ApplicationController @search_term = params[:search] @sort = params[:sort] || default_sort - @search_service = Gitlab::View::Presenter::Factory.new(search_service, current_user: current_user).fabricate! - @search_level = @search_service.level @search_type = search_type @global_search_duration_s = Benchmark.realtime do @scope = @search_service.scope - @without_count = @search_service.without_count? - @show_snippets = @search_service.show_snippets? @search_results = @search_service.search_results @search_objects = @search_service.search_objects @search_highlight = @search_service.search_highlight @@ -118,8 +119,22 @@ class SearchController < ApplicationController def opensearch end + def elasticsearch_in_use + search_service.respond_to?(:use_elasticsearch?) && search_service.use_elasticsearch? + end + strong_memoize_attr :elasticsearch_in_use + private + def update_scope_for_code_search + return if params[:scope] == 'blobs' + return unless params[:search].present? + + if CODE_SEARCH_LITERALS.any? { |literal| literal.in? params[:search] } + redirect_to search_path(safe_params.except(:controller, :action).merge(scope: 'blobs')) + end + end + # overridden in EE def default_sort 'created_desc' diff --git a/app/controllers/snippets/application_controller.rb b/app/controllers/snippets/application_controller.rb index f259f4569ef..64adc4e6611 100644 --- a/app/controllers/snippets/application_controller.rb +++ b/app/controllers/snippets/application_controller.rb @@ -4,7 +4,7 @@ class Snippets::ApplicationController < ApplicationController include FindSnippet include SnippetAuthorizations - feature_category :snippets + feature_category :source_code_management private diff --git a/app/controllers/snippets/notes_controller.rb b/app/controllers/snippets/notes_controller.rb index 8a4e8edbf3c..9e23eef4178 100644 --- a/app/controllers/snippets/notes_controller.rb +++ b/app/controllers/snippets/notes_controller.rb @@ -8,7 +8,7 @@ class Snippets::NotesController < ApplicationController before_action :authorize_read_snippet!, only: [:show, :index] before_action :authorize_create_note!, only: [:create] - feature_category :snippets + feature_category :source_code_management private diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 0f03333d793..f23e513e419 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -31,8 +31,7 @@ class UsersController < ApplicationController :followers, :following, :calendar, :calendar_activities, :exists, :activity, :follow, :unfollow, :ssh_keys] - feature_category :snippets, [:snippets] - feature_category :source_code_management, [:gpg_keys] + feature_category :source_code_management, [:snippets, :gpg_keys] # TODO: Set higher urgency after resolving https://gitlab.com/gitlab-org/gitlab/-/issues/357914 urgency :low, [:show, :calendar_activities, :contributed, :activity, :projects, :groups, :calendar, :snippets] diff --git a/app/controllers/web_ide/remote_ide_controller.rb b/app/controllers/web_ide/remote_ide_controller.rb new file mode 100644 index 00000000000..fe70e78b1e5 --- /dev/null +++ b/app/controllers/web_ide/remote_ide_controller.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'uri' + +module WebIde + class RemoteIdeController < ApplicationController + include VSCodeCDNCSP + + rescue_from URI::InvalidComponentError, with: :render_404 + + before_action :allow_remote_ide_content_security_policy + + feature_category :remote_development + + urgency :low + + def index + return render_404 unless Feature.enabled?(:vscode_web_ide, current_user) + + render layout: 'fullscreen', locals: { minimal: true, data: root_element_data } + end + + private + + def allow_remote_ide_content_security_policy + return if request.content_security_policy.directives.blank? + + default_src = Array(request.content_security_policy.directives['default-src'] || []) + + request.content_security_policy.directives['connect-src'] ||= default_src + request.content_security_policy.directives['connect-src'].concat(connect_src_urls) + end + + def connect_src_urls + # It's okay if "port" is null + host, port = params.require(:remote_host).split(':') + + # This could throw URI::InvalidComponentError. We go ahead and let it throw + # and let the controller recover with a bad_request response + %w[ws wss http https].map { |scheme| URI::Generic.build(scheme: scheme, host: host, port: port).to_s } + end + + def root_element_data + { + connection_token: params.fetch(:connection_token, ''), + remote_host: params.require(:remote_host), + remote_path: params.fetch(:remote_path, ''), + return_url: params.fetch(:return_url, ''), + csp_nonce: content_security_policy_nonce + } + end + end +end diff --git a/app/events/gitlab_subscriptions/renewed_event.rb b/app/events/gitlab_subscriptions/renewed_event.rb new file mode 100644 index 00000000000..02bf8ec7095 --- /dev/null +++ b/app/events/gitlab_subscriptions/renewed_event.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module GitlabSubscriptions + class RenewedEvent < Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'required' => %w[ + namespace_id + ], + 'properties' => { + 'namespace_id' => { 'type' => 'integer' } + } + } + end + end +end diff --git a/app/experiments/concerns/project_commit_count.rb b/app/experiments/concerns/project_commit_count.rb index 706a1a24640..3f08538c21f 100644 --- a/app/experiments/concerns/project_commit_count.rb +++ b/app/experiments/concerns/project_commit_count.rb @@ -10,9 +10,9 @@ module ProjectCommitCount return default_count unless root_ref Gitlab::GitalyClient::CommitService.new(raw_repo).commit_count(root_ref, { - all: true, # include all branches - max_count: max_count # limit as an optimization - }) + all: true, # include all branches + max_count: max_count # limit as an optimization + }) rescue StandardError => e Gitlab::ErrorTracking.track_exception(e, exception_details) diff --git a/app/finders/autocomplete/routes_finder.rb b/app/finders/autocomplete/routes_finder.rb index 858a4b69376..ecede0c1c1c 100644 --- a/app/finders/autocomplete/routes_finder.rb +++ b/app/finders/autocomplete/routes_finder.rb @@ -13,7 +13,7 @@ module Autocomplete end def execute - return [] if @search.blank? + return Route.none if @search.blank? Route .for_routable(routables) @@ -30,7 +30,7 @@ module Autocomplete class NamespacesOnly < self def routables - return Namespace.without_project_namespaces if current_user.admin? + return Namespace.without_project_namespaces if current_user.can_admin_all_resources? current_user.namespaces end @@ -38,7 +38,7 @@ module Autocomplete class ProjectsOnly < self def routables - return Project.all if current_user.admin? + return Project.all if current_user.can_admin_all_resources? current_user.projects end diff --git a/app/finders/ci/freeze_periods_finder.rb b/app/finders/ci/freeze_periods_finder.rb new file mode 100644 index 00000000000..91df776abe6 --- /dev/null +++ b/app/finders/ci/freeze_periods_finder.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Ci + class FreezePeriodsFinder + def initialize(project, current_user = nil) + @project = project + @current_user = current_user + end + + def execute + return Ci::FreezePeriod.none unless Ability.allowed?(@current_user, :read_freeze_period, @project) + + @project.freeze_periods + end + end +end diff --git a/app/finders/ci/jobs_finder.rb b/app/finders/ci/jobs_finder.rb index 152eb271694..1627e41a02d 100644 --- a/app/finders/ci/jobs_finder.rb +++ b/app/finders/ci/jobs_finder.rb @@ -16,6 +16,7 @@ module Ci def execute builds = init_collection.order_id_desc + builds = filter_by_with_artifacts(builds) filter_by_scope(builds) rescue Gitlab::Access::AccessDeniedError type.none @@ -30,7 +31,7 @@ module Ci end def all_jobs - raise Gitlab::Access::AccessDeniedError unless current_user&.admin? + raise Gitlab::Access::AccessDeniedError unless current_user&.can_admin_all_resources? type.all end @@ -72,6 +73,14 @@ module Ci end end + def filter_by_with_artifacts(builds) + if params[:with_artifacts] + builds.with_erasable_artifacts + else + builds + end + end + def filter_by_statuses!(builds) unknown_statuses = params[:scope] - ::CommitStatus::AVAILABLE_STATUSES raise ArgumentError, 'Scope contains invalid value(s)' unless unknown_statuses.empty? diff --git a/app/finders/ci/pipelines_finder.rb b/app/finders/ci/pipelines_finder.rb index 712d5f8c6fb..4c47517299a 100644 --- a/app/finders/ci/pipelines_finder.rb +++ b/app/finders/ci/pipelines_finder.rb @@ -36,6 +36,7 @@ module Ci items = by_yaml_errors(items) items = by_updated_at(items) items = by_source(items) + items = by_name(items) sort_items(items) end @@ -152,6 +153,15 @@ module Ci items end + def by_name(items) + return items unless + Feature.enabled?(:pipeline_name, project) && + Feature.enabled?(:pipeline_name_search, project) && + params[:name].present? + + items.for_name(params[:name]) + end + # rubocop: disable CodeReuse/ActiveRecord def sort_items(items) order_by = if ALLOWED_INDEXED_COLUMNS.include?(params[:order_by]) diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb index d0d98a59677..136d23939e2 100644 --- a/app/finders/ci/runners_finder.rb +++ b/app/finders/ci/runners_finder.rb @@ -10,6 +10,7 @@ module Ci def initialize(current_user:, params:) @params = params @group = params.delete(:group) + @project = params.delete(:project) @current_user = current_user end @@ -36,13 +37,19 @@ module Ci private def search! - @group ? group_runners : all_runners + if @project && Feature.enabled?(:on_demand_scans_runner_tags, @project) + project_runners + elsif @group + group_runners + else + all_runners + end @runners = @runners.search(@params[:search]) if @params[:search].present? end def all_runners - raise Gitlab::Access::AccessDeniedError unless @current_user&.admin? + raise Gitlab::Access::AccessDeniedError unless @current_user&.can_admin_all_resources? @runners = Ci::Runner.all end @@ -66,6 +73,12 @@ module Ci end end + def project_runners + raise Gitlab::Access::AccessDeniedError unless can?(@current_user, :admin_project, @project) + + @runners = ::Ci::Runner.owned_or_instance_wide(@project.id) + end + def filter_by_active! @runners = @runners.active(@params[:active]) if @params.include?(:active) end diff --git a/app/finders/clusters/agent_tokens_finder.rb b/app/finders/clusters/agent_tokens_finder.rb index e241836e1dc..72692777bc6 100644 --- a/app/finders/clusters/agent_tokens_finder.rb +++ b/app/finders/clusters/agent_tokens_finder.rb @@ -2,24 +2,30 @@ module Clusters class AgentTokensFinder - def initialize(object, current_user, agent_id) - @object = object + include FinderMethods + + def initialize(agent, current_user, params = {}) + @agent = agent @current_user = current_user - @agent_id = agent_id + @params = params end def execute - raise_not_found_unless_can_read_cluster + return ::Clusters::AgentToken.none unless can_read_cluster_agents? - object.cluster_agents.find(agent_id).agent_tokens + agent.agent_tokens.then { |agent_tokens| by_status(agent_tokens) } end private - attr_reader :object, :current_user, :agent_id + attr_reader :agent, :current_user, :params + + def by_status(agent_tokens) + params[:status].present? ? agent_tokens.with_status(params[:status]) : agent_tokens + end - def raise_not_found_unless_can_read_cluster - raise ActiveRecord::RecordNotFound unless current_user&.can?(:read_cluster, object) + def can_read_cluster_agents? + current_user&.can?(:read_cluster, agent&.project) end end end diff --git a/app/finders/deployments_finder.rb b/app/finders/deployments_finder.rb index 5b2139cb941..21869f6f31d 100644 --- a/app/finders/deployments_finder.rb +++ b/app/finders/deployments_finder.rb @@ -212,6 +212,7 @@ class DeploymentsFinder deployable: { job_artifacts: [], user: [], + metadata: [], pipeline: { project: { route: [], diff --git a/app/finders/environments/environments_finder.rb b/app/finders/environments/environments_finder.rb index f2dcba04349..85cd37c267e 100644 --- a/app/finders/environments/environments_finder.rb +++ b/app/finders/environments/environments_finder.rb @@ -41,7 +41,13 @@ module Environments def by_search(environments) if params[:search].present? - environments.for_name_like(params[:search], limit: nil) + if Feature.enabled?(:enable_environments_search_within_folder, project) + Environment.from_union( + environments.for_name_like(params[:search], limit: nil), + environments.for_name_like_within_folder(params[:search], limit: nil)) + else + environments.for_name_like(params[:search], limit: nil) + end else environments end @@ -57,7 +63,7 @@ module Environments def by_ids(environments) if params[:environment_ids].present? - environments.for_id(params[:environment_ids]) + environments.id_in(params[:environment_ids]) else environments end diff --git a/app/finders/freeze_periods_finder.rb b/app/finders/freeze_periods_finder.rb deleted file mode 100644 index 2a9bfbe12ba..00000000000 --- a/app/finders/freeze_periods_finder.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -class FreezePeriodsFinder - def initialize(project, current_user = nil) - @project = project - @current_user = current_user - end - - def execute - return Ci::FreezePeriod.none unless Ability.allowed?(@current_user, :read_freeze_period, @project) - - @project.freeze_periods - end -end diff --git a/app/finders/git_refs_finder.rb b/app/finders/git_refs_finder.rb index dbe0060d8ae..0492dd9934f 100644 --- a/app/finders/git_refs_finder.rb +++ b/app/finders/git_refs_finder.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class GitRefsFinder + include Gitlab::Utils::StrongMemoize + def initialize(repository, params = {}) @repository = repository @params = params @@ -10,44 +12,28 @@ class GitRefsFinder attr_reader :repository, :params - def search - @params[:search].to_s.presence - end - - def sort - @params[:sort].to_s.presence || 'name' - end - def by_search(refs) return refs unless search - case search - when ->(v) { v.starts_with?('^') } - filter_refs_with_prefix(refs, search.slice(1..-1)) - when ->(v) { v.ends_with?('$') } - filter_refs_with_suffix(refs, search.chop) - else - matches = filter_refs_by_name(refs, search) - set_exact_match_as_first_result(matches, search) - end - end - - def filter_refs_with_prefix(refs, prefix) - prefix = prefix.downcase + matches = filter_refs(refs, search) + return matches if regex_search? - refs.select { |ref| ref.name.downcase.starts_with?(prefix) } + set_exact_match_as_first_result(matches, search) end - def filter_refs_with_suffix(refs, suffix) - suffix = suffix.downcase - - refs.select { |ref| ref.name.downcase.ends_with?(suffix) } + def search + @params[:search].to_s.presence end + strong_memoize_attr :search - def filter_refs_by_name(refs, term) - term = term.downcase + def sort + @params[:sort].to_s.presence || 'name' + end - refs.select { |ref| ref.name.downcase.include?(term) } + def filter_refs(refs, term) + regex_string = Regexp.quote(term.downcase) + regex_string = unescape_regex_operators(regex_string) if regex_search? + refs.select { |ref| /#{regex_string}/ === ref.name.downcase } end def set_exact_match_as_first_result(matches, term) @@ -59,4 +45,13 @@ class GitRefsFinder def find_exact_match_index(matches, term) matches.index { |ref| ref.name.casecmp(term) == 0 } end + + def regex_search? + Regexp.union('^', '$', '*') === search + end + strong_memoize_attr :regex_search?, :regex_search + + def unescape_regex_operators(regex_string) + regex_string.sub('\^', '^').gsub('\*', '.*?').sub('\$', '$') + end end diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb index 42cd06c8066..033af0f42a6 100644 --- a/app/finders/group_descendants_finder.rb +++ b/app/finders/group_descendants_finder.rb @@ -22,7 +22,7 @@ class GroupDescendantsFinder attr_reader :current_user, :parent_group, :params - def initialize(current_user: nil, parent_group:, params: {}) + def initialize(parent_group:, current_user: nil, params: {}) @current_user = current_user @parent_group = parent_group @params = params.reverse_merge(non_archived: params[:archived].blank?, not_aimed_for_deletion: true) diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb index 4688d561897..47ed623b252 100644 --- a/app/finders/group_members_finder.rb +++ b/app/finders/group_members_finder.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true class GroupMembersFinder < UnionFinder - RELATIONS = %i(direct inherited descendants shared_from_groups).freeze - DEFAULT_RELATIONS = %i(direct inherited).freeze + RELATIONS = %i[direct inherited descendants shared_from_groups].freeze + DEFAULT_RELATIONS = %i[direct inherited].freeze INVALID_RELATION_TYPE_ERROR_MSG = "is not a valid relation type. Valid relation types are #{RELATIONS.join(', ')}." RELATIONS_DESCRIPTIONS = { diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb index e68a0c8fca9..de6eacbb1e0 100644 --- a/app/finders/members_finder.rb +++ b/app/finders/members_finder.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true class MembersFinder - RELATIONS = %i(direct inherited descendants invited_groups).freeze - DEFAULT_RELATIONS = %i(direct inherited).freeze + RELATIONS = %i[direct inherited descendants invited_groups].freeze + DEFAULT_RELATIONS = %i[direct inherited].freeze # Params can be any of the following: # sort: string diff --git a/app/finders/merge_request_target_project_finder.rb b/app/finders/merge_request_target_project_finder.rb index dc9b28ab0a0..fdb3bac8935 100644 --- a/app/finders/merge_request_target_project_finder.rb +++ b/app/finders/merge_request_target_project_finder.rb @@ -5,7 +5,7 @@ class MergeRequestTargetProjectFinder attr_reader :current_user, :source_project - def initialize(current_user: nil, source_project:, project_feature: :merge_requests) + def initialize(source_project:, current_user: nil, project_feature: :merge_requests) @current_user = current_user @source_project = source_project @project_feature = project_feature diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb index 42bd7a24888..7890502cf0e 100644 --- a/app/finders/notes_finder.rb +++ b/app/finders/notes_finder.rb @@ -65,7 +65,7 @@ class NotesFinder @target = if target_type == "commit" - if Ability.allowed?(@current_user, :download_code, @project) + if Ability.allowed?(@current_user, :read_code, @project) @project.commit(target_id) end else @@ -101,7 +101,7 @@ class NotesFinder # rubocop: disable CodeReuse/ActiveRecord def notes_of_any_type - types = %w(commit issue merge_request snippet) + types = %w[commit issue merge_request snippet] note_relations = types.map { |t| notes_for_type(t) } note_relations.map! { |notes| search(notes) } UnionFinder.new.find_union(note_relations, Note.includes(:author)) # rubocop: disable CodeReuse/Finder @@ -126,7 +126,7 @@ class NotesFinder # rubocop: disable CodeReuse/ActiveRecord def notes_for_type(noteable_type) if noteable_type == "commit" - if Ability.allowed?(@current_user, :download_code, @project) + if Ability.allowed?(@current_user, :read_code, @project) @project.notes.where(noteable_type: 'Commit') else Note.none diff --git a/app/finders/personal_access_tokens_finder.rb b/app/finders/personal_access_tokens_finder.rb index 8403c531945..5af08cf0660 100644 --- a/app/finders/personal_access_tokens_finder.rb +++ b/app/finders/personal_access_tokens_finder.rb @@ -33,7 +33,7 @@ class PersonalAccessTokensFinder attr_reader :current_user def by_current_user(tokens) - return tokens if current_user.nil? || current_user.admin? + return tokens if current_user.nil? || current_user.can_admin_all_resources? return PersonalAccessToken.none unless Ability.allowed?(current_user, :read_user_personal_access_tokens, params[:user]) tokens diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 126687ae41f..1afd5adeada 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -89,6 +89,7 @@ class ProjectsFinder < UnionFinder collection = by_not_aimed_for_deletion(collection) collection = by_last_activity_after(collection) collection = by_last_activity_before(collection) + collection = by_language(collection) by_repository_storage(collection) end @@ -97,12 +98,10 @@ class ProjectsFinder < UnionFinder current_user.owned_projects elsif min_access_level? current_user.authorized_projects(params[:min_access_level]) + elsif private_only? || impossible_visibility_level? + current_user.authorized_projects else - if private_only? || impossible_visibility_level? - current_user.authorized_projects - else - Project.public_or_visible_to_user(current_user) - end + Project.public_or_visible_to_user(current_user) end end @@ -239,6 +238,14 @@ class ProjectsFinder < UnionFinder end end + def by_language(items) + if Feature.enabled?(:project_language_search, current_user) && params[:language].present? + items.with_programming_language_id(params[:language]) + else + items + end + end + def sort(items) if params[:sort].present? items.sort_by_attribute(params[:sort]) diff --git a/app/finders/releases/group_releases_finder.rb b/app/finders/releases/group_releases_finder.rb index 08530f63ea6..67784d6579c 100644 --- a/app/finders/releases/group_releases_finder.rb +++ b/app/finders/releases/group_releases_finder.rb @@ -33,8 +33,8 @@ module Releases Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder.new( scope: releases_scope, array_scope: Project.for_group_and_its_subgroups(parent).select(:id), - array_mapping_scope: -> (project_id_expression) { Release.where(Release.arel_table[:project_id].eq(project_id_expression)) }, - finder_query: -> (order_by, id_expression) { Release.where(Release.arel_table[:id].eq(id_expression)) } + array_mapping_scope: ->(project_id_expression) { Release.where(Release.arel_table[:project_id].eq(project_id_expression)) }, + finder_query: ->(order_by, id_expression) { Release.where(Release.arel_table[:id].eq(id_expression)) } ) .execute end diff --git a/app/finders/repositories/tree_finder.rb b/app/finders/repositories/tree_finder.rb index 2ea5a8856ec..231c1de1513 100644 --- a/app/finders/repositories/tree_finder.rb +++ b/app/finders/repositories/tree_finder.rb @@ -1,15 +1,13 @@ # frozen_string_literal: true module Repositories - class TreeFinder < GitRefsFinder - attr_reader :user_project - + class TreeFinder CommitMissingError = Class.new(StandardError) - def initialize(user_project, params = {}) - super(user_project.repository, params) - - @user_project = user_project + def initialize(project, params = {}) + @project = project + @repository = project.repository + @params = params end def execute(gitaly_pagination: false) @@ -17,15 +15,15 @@ module Repositories request_params = { recursive: recursive } request_params[:pagination_params] = pagination_params if gitaly_pagination - tree = user_project.repository.tree(commit.id, path, **request_params) - tree.sorted_entries + repository.tree(commit.id, path, **request_params).sorted_entries end def total # This is inefficient and we'll look at replacing this implementation - Gitlab::Cache.fetch_once([user_project, repository.commit, :tree_size, commit.id, path, recursive]) do - user_project.repository.tree(commit.id, path, recursive: recursive).entries.size + cache_key = [project, repository.commit, :tree_size, commit.id, path, recursive] + Gitlab::Cache.fetch_once(cache_key) do + repository.tree(commit.id, path, recursive: recursive).entries.size end end @@ -35,12 +33,14 @@ module Repositories private + attr_reader :project, :repository, :params + def commit - @commit ||= user_project.commit(ref) + @commit ||= project.commit(ref) end def ref - params[:ref] || user_project.default_branch + params[:ref] || project.default_branch end def path diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index e83018ed24c..0bf31ea33dd 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -24,7 +24,7 @@ class TodosFinder NONE = '0' - TODO_TYPES = Set.new(%w(Issue MergeRequest DesignManagement::Design AlertManagement::Alert)).freeze + TODO_TYPES = Set.new(%w[Issue WorkItem MergeRequest DesignManagement::Design AlertManagement::Alert]).freeze attr_accessor :current_user, :params diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb index 9c2462b42a6..11e3c341c1f 100644 --- a/app/finders/users_finder.rb +++ b/app/finders/users_finder.rb @@ -55,7 +55,7 @@ class UsersFinder private def base_scope - scope = current_user&.admin? ? User.all : User.without_forbidden_states + scope = current_user&.can_admin_all_resources? ? User.all : User.without_forbidden_states scope.order_id_desc end @@ -80,7 +80,7 @@ class UsersFinder def by_search(users) return users unless params[:search].present? - users.search(params[:search], with_private_emails: current_user&.admin?) + users.search(params[:search], with_private_emails: current_user&.can_admin_all_resources?) end def by_blocked(users) @@ -97,7 +97,7 @@ class UsersFinder # rubocop: disable CodeReuse/ActiveRecord def by_external_identity(users) - return users unless current_user&.admin? && params[:extern_uid] && params[:provider] + return users unless current_user&.can_admin_all_resources? && params[:extern_uid] && params[:provider] users.joins(:identities).merge(Identity.with_extern_uid(params[:provider], params[:extern_uid])) end diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb index 710e7fe110c..7f83b62a2ff 100644 --- a/app/graphql/graphql_triggers.rb +++ b/app/graphql/graphql_triggers.rb @@ -44,6 +44,14 @@ module GraphqlTriggers merge_request ) end + + def self.merge_request_approval_state_updated(merge_request) + GitlabSchema.subscriptions.trigger( + 'mergeRequestApprovalStateUpdated', + { issuable_id: merge_request.to_gid }, + merge_request + ) + end end GraphqlTriggers.prepend_mod diff --git a/app/graphql/mutations/alert_management/alerts/set_assignees.rb b/app/graphql/mutations/alert_management/alerts/set_assignees.rb index c986111d290..500e2b868b1 100644 --- a/app/graphql/mutations/alert_management/alerts/set_assignees.rb +++ b/app/graphql/mutations/alert_management/alerts/set_assignees.rb @@ -20,7 +20,7 @@ module Mutations alert = authorized_find!(project_path: args[:project_path], iid: args[:iid]) result = set_assignees(alert, args[:assignee_usernames], args[:operation_mode]) - track_usage_event(:incident_management_alert_assigned, current_user.id) + track_alert_events('incident_management_alert_assigned', alert) prepare_response(result) end diff --git a/app/graphql/mutations/alert_management/alerts/todo/create.rb b/app/graphql/mutations/alert_management/alerts/todo/create.rb index 2a1056e8f64..999c0bec5af 100644 --- a/app/graphql/mutations/alert_management/alerts/todo/create.rb +++ b/app/graphql/mutations/alert_management/alerts/todo/create.rb @@ -11,7 +11,7 @@ module Mutations alert = authorized_find!(project_path: args[:project_path], iid: args[:iid]) result = ::AlertManagement::Alerts::Todo::CreateService.new(alert, current_user).execute - track_usage_event(:incident_management_alert_todo, current_user.id) + track_alert_events('incident_management_alert_todo', alert) prepare_response(result) end diff --git a/app/graphql/mutations/alert_management/base.rb b/app/graphql/mutations/alert_management/base.rb index d01f200107c..2eef6bb9db7 100644 --- a/app/graphql/mutations/alert_management/base.rb +++ b/app/graphql/mutations/alert_management/base.rb @@ -39,6 +39,24 @@ module Mutations ::AlertManagement::AlertsFinder.new(current_user, project, args).execute.first end + + def track_alert_events(event, alert) + project = alert.project + namespace = project.namespace + track_usage_event(event, current_user.id) + + return unless Feature.enabled?(:route_hll_to_snowplow_phase2, namespace) + + Gitlab::Tracking.event( + self.class.to_s, + event, + project: project, + namespace: namespace, + user: current_user, + label: 'redis_hll_counters.incident_management.incident_management_total_unique_counts_monthly', + context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: event).to_context] + ) + end end end end diff --git a/app/graphql/mutations/alert_management/create_alert_issue.rb b/app/graphql/mutations/alert_management/create_alert_issue.rb index 77a7d7a4147..7c8de6365e7 100644 --- a/app/graphql/mutations/alert_management/create_alert_issue.rb +++ b/app/graphql/mutations/alert_management/create_alert_issue.rb @@ -9,7 +9,7 @@ module Mutations alert = authorized_find!(project_path: args[:project_path], iid: args[:iid]) result = create_alert_issue(alert, current_user) - track_usage_event(:incident_management_incident_created, current_user.id) + track_alert_events('incident_management_incident_created', alert) track_usage_event(:incident_management_alert_create_incident, current_user.id) prepare_response(alert, result) diff --git a/app/graphql/mutations/alert_management/update_alert_status.rb b/app/graphql/mutations/alert_management/update_alert_status.rb index 21566c7d66f..be271a7d795 100644 --- a/app/graphql/mutations/alert_management/update_alert_status.rb +++ b/app/graphql/mutations/alert_management/update_alert_status.rb @@ -13,7 +13,7 @@ module Mutations alert = authorized_find!(project_path: project_path, iid: iid) result = update_status(alert, status) - track_usage_event(:incident_management_alert_status_changed, current_user.id) + track_alert_events('incident_management_alert_status_changed', alert) prepare_response(result) end diff --git a/app/graphql/mutations/ci/pipeline_schedule/create.rb b/app/graphql/mutations/ci/pipeline_schedule/create.rb new file mode 100644 index 00000000000..65b355cd80f --- /dev/null +++ b/app/graphql/mutations/ci/pipeline_schedule/create.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module PipelineSchedule + class Create < BaseMutation + graphql_name 'PipelineScheduleCreate' + + include FindsProject + + authorize :create_pipeline_schedule + + argument :project_path, GraphQL::Types::ID, + required: true, + description: 'Full path of the project the pipeline schedule is associated with.' + + argument :description, GraphQL::Types::String, + required: true, + description: 'Description of the pipeline schedule.' + + argument :cron, GraphQL::Types::String, + required: true, + description: 'Cron expression of the pipeline schedule.' + + argument :cron_timezone, GraphQL::Types::String, + required: false, + description: + <<-STR + Cron time zone supported by ActiveSupport::TimeZone. + For example: "Pacific Time (US & Canada)" (default: "UTC"). + STR + + argument :ref, GraphQL::Types::String, + required: true, + description: 'Ref of the pipeline schedule.' + + argument :active, GraphQL::Types::Boolean, + required: false, + description: 'Indicates if the pipeline schedule should be active or not.' + + argument :variables, [Mutations::Ci::PipelineSchedule::VariableInputType], + required: false, + description: 'Variables for the pipeline schedule.' + + field :pipeline_schedule, + Types::Ci::PipelineScheduleType, + description: 'Created pipeline schedule.' + + def resolve(project_path:, variables: [], **pipeline_schedule_attrs) + project = authorized_find!(project_path) + + params = pipeline_schedule_attrs.merge(variables_attributes: variables.map(&:to_h)) + + schedule = ::Ci::CreatePipelineScheduleService + .new(project, current_user, params) + .execute + + unless schedule.persisted? + return { + pipeline_schedule: nil, errors: schedule.errors.full_messages + } + end + + { + pipeline_schedule: schedule, + errors: [] + } + end + end + end + end +end diff --git a/app/graphql/mutations/ci/pipeline_schedule/play.rb b/app/graphql/mutations/ci/pipeline_schedule/play.rb new file mode 100644 index 00000000000..056890852c9 --- /dev/null +++ b/app/graphql/mutations/ci/pipeline_schedule/play.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module PipelineSchedule + class Play < Base + graphql_name 'PipelineSchedulePlay' + + authorize :play_pipeline_schedule + + field :pipeline_schedule, + Types::Ci::PipelineScheduleType, + null: true, + description: 'Pipeline schedule after mutation.' + + def resolve(id:) + schedule = authorized_find!(id: id) + + job_id = ::Ci::PipelineScheduleService + .new(schedule.project, current_user) + .execute(schedule) + + if job_id + { pipeline_schedule: schedule, errors: [] } + else + { pipeline_schedule: nil, errors: ['Unable to schedule a pipeline to run immediately.'] } + end + end + end + end + end +end diff --git a/app/graphql/mutations/ci/pipeline_schedule/variable_input_type.rb b/app/graphql/mutations/ci/pipeline_schedule/variable_input_type.rb new file mode 100644 index 00000000000..54a6ad92448 --- /dev/null +++ b/app/graphql/mutations/ci/pipeline_schedule/variable_input_type.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module PipelineSchedule + class VariableInputType < Types::BaseInputObject + graphql_name 'PipelineScheduleVariableInput' + + description 'Attributes for the pipeline schedule variable.' + + argument :key, GraphQL::Types::String, required: true, description: 'Name of the variable.' + + argument :value, GraphQL::Types::String, required: true, description: 'Value of the variable.' + + argument :variable_type, Types::Ci::VariableTypeEnum, required: true, description: 'Type of the variable.' + end + end + end +end diff --git a/app/graphql/mutations/ci/runner/update.rb b/app/graphql/mutations/ci/runner/update.rb index 3c99cde60a4..4f0bf19f09c 100644 --- a/app/graphql/mutations/ci/runner/update.rb +++ b/app/graphql/mutations/ci/runner/update.rb @@ -54,7 +54,7 @@ module Mutations argument :associated_projects, [::Types::GlobalIDType[::Project]], required: false, description: 'Projects associated with the runner. Available only for project runners.', - prepare: -> (global_ids, ctx) { global_ids&.filter_map { |gid| gid.model_id.to_i } } + prepare: ->(global_ids, ctx) { global_ids&.filter_map { |gid| gid.model_id.to_i } } field :runner, Types::Ci::RunnerType, diff --git a/app/graphql/mutations/clusters/agent_tokens/create.rb b/app/graphql/mutations/clusters/agent_tokens/create.rb index a99a54fa5ed..c10e1633350 100644 --- a/app/graphql/mutations/clusters/agent_tokens/create.rb +++ b/app/graphql/mutations/clusters/agent_tokens/create.rb @@ -49,9 +49,9 @@ module Mutations payload = result.payload { - secret: payload[:secret], - token: payload[:token], - errors: Array.wrap(result.message) + secret: payload[:secret], + token: payload[:token], + errors: Array.wrap(result.message) } end diff --git a/app/graphql/mutations/container_repositories/destroy.rb b/app/graphql/mutations/container_repositories/destroy.rb index fe1c3fe4e61..c3bd7acf444 100644 --- a/app/graphql/mutations/container_repositories/destroy.rb +++ b/app/graphql/mutations/container_repositories/destroy.rb @@ -22,10 +22,6 @@ module Mutations container_repository.delete_scheduled! - unless Feature.enabled?(:container_registry_delete_repository_with_cron_worker) - DeleteContainerRepositoryWorker.perform_async(current_user.id, container_repository.id) # rubocop:disable CodeReuse/Worker - end - track_event(:delete_repository, :container) { diff --git a/app/graphql/mutations/incident_management/timeline_event/update.rb b/app/graphql/mutations/incident_management/timeline_event/update.rb index 1f53bdc19cb..b35feed3082 100644 --- a/app/graphql/mutations/incident_management/timeline_event/update.rb +++ b/app/graphql/mutations/incident_management/timeline_event/update.rb @@ -18,6 +18,10 @@ module Mutations required: false, description: 'Timestamp when the event occurred.' + argument :timeline_event_tag_names, [GraphQL::Types::String], + required: false, + description: copy_field_description(Types::IncidentManagement::TimelineEventType, :timeline_event_tags) + def resolve(id:, **args) timeline_event = authorized_find!(id: id) diff --git a/app/graphql/mutations/issues/link_alerts.rb b/app/graphql/mutations/issues/link_alerts.rb new file mode 100644 index 00000000000..c45e90c598f --- /dev/null +++ b/app/graphql/mutations/issues/link_alerts.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Issues + class LinkAlerts < Base + graphql_name 'IssueLinkAlerts' + + argument :alert_references, [GraphQL::Types::String], + required: true, + description: 'Alerts references to be linked to the incident.' + + authorize :admin_issue + + def resolve(project_path:, iid:, alert_references:) + issue = authorized_find!(project_path: project_path, iid: iid) + + ::IncidentManagement::LinkAlerts::CreateService.new(issue, current_user, alert_references).execute + + { + issue: issue, + errors: errors_on_object(issue) + } + end + end + end +end diff --git a/app/graphql/mutations/issues/unlink_alert.rb b/app/graphql/mutations/issues/unlink_alert.rb new file mode 100644 index 00000000000..a11af4133cf --- /dev/null +++ b/app/graphql/mutations/issues/unlink_alert.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Mutations + module Issues + class UnlinkAlert < Base + graphql_name 'IssueUnlinkAlert' + + argument :alert_id, ::Types::GlobalIDType[::AlertManagement::Alert], + required: true, + description: 'Global ID of the alert to unlink from the incident.' + + authorize :admin_issue + + def resolve(project_path:, iid:, alert_id:) + issue = authorized_find!(project_path: project_path, iid: iid) + alert = find_alert_by_gid(alert_id) + + result = ::IncidentManagement::LinkAlerts::DestroyService.new(issue, current_user, alert).execute + + { + issue: issue, + errors: result.errors + } + end + + private + + def find_alert_by_gid(alert_id) + ::Gitlab::Graphql::Lazy.force(GitlabSchema.object_from_id(alert_id, expected_type: ::AlertManagement::Alert)) + end + end + end +end diff --git a/app/graphql/mutations/notes/create/diff_note.rb b/app/graphql/mutations/notes/create/diff_note.rb index 7b8c06fd104..df2bd55106e 100644 --- a/app/graphql/mutations/notes/create/diff_note.rb +++ b/app/graphql/mutations/notes/create/diff_note.rb @@ -31,10 +31,10 @@ module Mutations def create_note_params(noteable, args) super(noteable, args).merge({ - type: 'DiffNote', - position: position(noteable, args), - merge_request_diff_head_sha: args[:position][:head_sha] - }) + type: 'DiffNote', + position: position(noteable, args), + merge_request_diff_head_sha: args[:position][:head_sha] + }) end def position(noteable, args) diff --git a/app/graphql/mutations/notes/create/image_diff_note.rb b/app/graphql/mutations/notes/create/image_diff_note.rb index d94fd4d6ff8..3de93e4f5c1 100644 --- a/app/graphql/mutations/notes/create/image_diff_note.rb +++ b/app/graphql/mutations/notes/create/image_diff_note.rb @@ -15,9 +15,9 @@ module Mutations def create_note_params(noteable, args) super(noteable, args).merge({ - type: 'DiffNote', - position: position(noteable, args) - }) + type: 'DiffNote', + position: position(noteable, args) + }) end def position(noteable, args) diff --git a/app/graphql/mutations/notes/create/note.rb b/app/graphql/mutations/notes/create/note.rb index 4d6f056de09..9b105b7fe1c 100644 --- a/app/graphql/mutations/notes/create/note.rb +++ b/app/graphql/mutations/notes/create/note.rb @@ -31,9 +31,9 @@ module Mutations end super(noteable, args).merge({ - in_reply_to_discussion_id: discussion_id, - merge_request_diff_head_sha: args[:merge_request_diff_head_sha] - }) + in_reply_to_discussion_id: discussion_id, + merge_request_diff_head_sha: args[:merge_request_diff_head_sha] + }) end def authorize_discussion!(discussion) diff --git a/app/graphql/mutations/timelogs/create.rb b/app/graphql/mutations/timelogs/create.rb index bab7508454e..1be023eed8a 100644 --- a/app/graphql/mutations/timelogs/create.rb +++ b/app/graphql/mutations/timelogs/create.rb @@ -11,7 +11,7 @@ module Mutations description: 'Amount of time spent.' argument :spent_at, - Types::DateType, + Types::TimeType, required: true, description: 'When the time was spent.' @@ -28,8 +28,12 @@ module Mutations authorize :create_timelog def resolve(issuable_id:, time_spent:, spent_at:, summary:, **args) - issuable = authorized_find!(id: issuable_id) parsed_time_spent = Gitlab::TimeTrackingFormatter.parse(time_spent) + if parsed_time_spent.nil? + return { timelog: nil, errors: [_('Time spent must be formatted correctly. For example: 1h 30m.')] } + end + + issuable = authorized_find!(id: issuable_id) result = ::Timelogs::CreateService.new( issuable, parsed_time_spent, spent_at, summary, current_user diff --git a/app/graphql/mutations/todos/restore_many.rb b/app/graphql/mutations/todos/restore_many.rb index 20913a9e7da..f2f944860c2 100644 --- a/app/graphql/mutations/todos/restore_many.rb +++ b/app/graphql/mutations/todos/restore_many.rb @@ -23,9 +23,9 @@ module Mutations updated_ids = restore(todos) { - updated_ids: updated_ids, - todos: Todo.id_in(updated_ids), - errors: errors_on_objects(todos) + updated_ids: updated_ids, + todos: Todo.id_in(updated_ids), + errors: errors_on_objects(todos) } end diff --git a/app/graphql/mutations/work_items/create.rb b/app/graphql/mutations/work_items/create.rb index 793e5d3caf8..a4efffb69c1 100644 --- a/app/graphql/mutations/work_items/create.rb +++ b/app/graphql/mutations/work_items/create.rb @@ -73,3 +73,5 @@ module Mutations end end end + +Mutations::WorkItems::Create.prepend_mod diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb index 2b54a3fdd55..6f847221f1b 100644 --- a/app/graphql/resolvers/base_resolver.rb +++ b/app/graphql/resolvers/base_resolver.rb @@ -15,6 +15,13 @@ module Resolvers @calls_gitaly = true end + # This is a flag to allow us to use `complexity_multiplier` to compute complexity for connection + # fields(see BaseField#connection_complexity_multiplier) in resolvers that do external connection pagination, + # thus disabling the default `connection` option(see self.field_options method above). + def self.calculate_ext_conn_complexity + false + end + def self.field_options extra_options = { requires_argument: @requires_argument, @@ -116,7 +123,7 @@ module Resolvers # When fetching many items, additional complexity is added to the field # depending on how many items is fetched. For each item we add 1% of the # original complexity - this means that loading 100 items (our default - # maxp_age_size limit) doubles the original complexity. + # max_page_size limit) doubles the original complexity. # # Complexity is not increased when searching by specific ID(s), because # complexity difference is minimal in this case. diff --git a/app/graphql/resolvers/ci/project_runners_resolver.rb b/app/graphql/resolvers/ci/project_runners_resolver.rb new file mode 100644 index 00000000000..378fa73c065 --- /dev/null +++ b/app/graphql/resolvers/ci/project_runners_resolver.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + class ProjectRunnersResolver < RunnersResolver + type Types::Ci::RunnerType.connection_type, null: true + + def parent_param + raise 'Expected project missing' unless parent.is_a?(Project) + + { project: parent } + end + end + end +end diff --git a/app/graphql/resolvers/ci/runner_groups_resolver.rb b/app/graphql/resolvers/ci/runner_groups_resolver.rb new file mode 100644 index 00000000000..3360e820bd2 --- /dev/null +++ b/app/graphql/resolvers/ci/runner_groups_resolver.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + class RunnerGroupsResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + include ResolvesGroups + + type Types::GroupConnection, null: true + + authorize :read_runner + authorizes_object! + + alias_method :runner, :object + + def resolve_with_lookahead(**args) + return unless runner.group_type? + + BatchLoader::GraphQL.for(runner.id).batch(key: :runner_namespaces) do |runner_ids, loader| + plucked_runner_and_namespace_ids = + ::Ci::RunnerNamespace + .for_runner(runner_ids) + .select(:runner_id, :namespace_id) + .pluck(:runner_id, :namespace_id) # rubocop: disable CodeReuse/ActiveRecord) + + namespace_ids = plucked_runner_and_namespace_ids.collect(&:last).uniq + groups = apply_lookahead(::Group.id_in(namespace_ids)) + Preloaders::GroupPolicyPreloader.new(groups, current_user).execute + groups_by_id = groups.index_by(&:id) + + runner_group_ids_by_runner_id = + plucked_runner_and_namespace_ids + .group_by { |runner_id, _namespace_id| runner_id } + .transform_values { |values| values.filter_map { |_runner_id, namespace_id| groups_by_id[namespace_id] } } + + runner_ids.each do |runner_id| + runner_namespaces = runner_group_ids_by_runner_id[runner_id] || [] + + loader.call(runner_id, runner_namespaces) + end + end + end + end + end +end diff --git a/app/graphql/resolvers/ci/runner_jobs_resolver.rb b/app/graphql/resolvers/ci/runner_jobs_resolver.rb index de00aadaea8..b818be3f018 100644 --- a/app/graphql/resolvers/ci/runner_jobs_resolver.rb +++ b/app/graphql/resolvers/ci/runner_jobs_resolver.rb @@ -27,9 +27,17 @@ module Resolvers def preloads { - previous_stage_jobs_and_needs: [:needs, :pipeline], + previous_stage_jobs_or_needs: [:needs, :pipeline], artifacts: [:job_artifacts], - pipeline: [:user] + pipeline: [:user], + detailed_status: [ + :metadata, + { pipeline: [:merge_request] }, + { project: [:route, { namespace: :route }] } + ], + commit_path: [:pipeline, { project: [:route, { namespace: [:route] }] }], + short_sha: [:pipeline], + tags: [:tags] } end end diff --git a/app/graphql/resolvers/ci/runner_owner_project_resolver.rb b/app/graphql/resolvers/ci/runner_owner_project_resolver.rb index da8fab93619..f4e044b81c9 100644 --- a/app/graphql/resolvers/ci/runner_owner_project_resolver.rb +++ b/app/graphql/resolvers/ci/runner_owner_project_resolver.rb @@ -13,20 +13,22 @@ module Resolvers resolve_owner end - def preloads - { - full_path: [:route] - } - end - private - def filtered_preloads - selection = lookahead + def node_selection(selection = lookahead) + # There are no nodes or edges selections in RunnerOwnerProjectResolver, but rather a project directly + selection + end + + def unconditional_includes + [:project_feature] + end - preloads.each.flat_map do |name, requirements| - selection&.selects?(name) ? requirements : [] - end + def preloads + { + full_path: [:route, { namespace: [:route] }], + web_url: [:route, { namespace: [:route] }] + } end def resolve_owner @@ -48,7 +50,7 @@ module Resolvers .transform_values { |runner_projects| runner_projects.first.project_id } project_ids = owner_project_id_by_runner_id.values.uniq - projects = Project.where(id: project_ids) + projects = apply_lookahead(Project.id_in(project_ids)) Preloaders::ProjectPolicyPreloader.new(projects, current_user).execute projects_by_id = projects.index_by(&:id) diff --git a/app/graphql/resolvers/ci/runner_projects_resolver.rb b/app/graphql/resolvers/ci/runner_projects_resolver.rb index af9a67acfda..2a2d63f85de 100644 --- a/app/graphql/resolvers/ci/runner_projects_resolver.rb +++ b/app/graphql/resolvers/ci/runner_projects_resolver.rb @@ -40,6 +40,7 @@ module Resolvers params: project_finder_params(args), project_ids_relation: project_ids) .execute + projects = apply_lookahead(projects) Preloaders::ProjectPolicyPreloader.new(projects, current_user).execute projects_by_id = projects.index_by(&:id) @@ -58,6 +59,19 @@ module Resolvers end # rubocop:enable CodeReuse/ActiveRecord end + + private + + def unconditional_includes + [:project_feature] + end + + def preloads + super.merge({ + full_path: [:route, { namespace: [:route] }], + web_url: [:route, { namespace: [:route] }] + }) + end end end end diff --git a/app/graphql/resolvers/clusters/agent_tokens_resolver.rb b/app/graphql/resolvers/clusters/agent_tokens_resolver.rb index 9740bc6bb6a..b7355a1752e 100644 --- a/app/graphql/resolvers/clusters/agent_tokens_resolver.rb +++ b/app/graphql/resolvers/clusters/agent_tokens_resolver.rb @@ -14,18 +14,7 @@ module Resolvers description: 'Status of the token.' def resolve(**args) - return ::Clusters::AgentToken.none unless can_read_agent_tokens? - - tokens = agent.agent_tokens - tokens = tokens.with_status(args[:status]) if args[:status].present? - - tokens - end - - private - - def can_read_agent_tokens? - current_user.can?(:read_cluster, project) + ::Clusters::AgentTokensFinder.new(agent, current_user, args).execute end end end diff --git a/app/graphql/resolvers/concerns/looks_ahead.rb b/app/graphql/resolvers/concerns/looks_ahead.rb index 81099c04e9f..1d532eb2486 100644 --- a/app/graphql/resolvers/concerns/looks_ahead.rb +++ b/app/graphql/resolvers/concerns/looks_ahead.rb @@ -39,7 +39,7 @@ module LooksAhead def filtered_preloads nodes = node_selection - return [] unless nodes + return [] unless nodes&.selected? selected_fields = nodes.selections.map(&:name) root_level_preloads = preloads_from_node_selection(selected_fields, preloads) @@ -65,13 +65,13 @@ module LooksAhead end.flatten end - def node_selection - return unless lookahead + def node_selection(selection = lookahead) + return selection unless selection&.selected? + return selection.selection(:edges).selection(:node) if selection.selects?(:edges) - if lookahead.selects?(:nodes) - lookahead.selection(:nodes) - elsif lookahead.selects?(:edges) - lookahead.selection(:edges).selection(:node) - end + # Will return a NullSelection object if :nodes is not a selection. This + # is better than returning nil as we can continue chaining selections on + # without raising errors. + selection.selection(:nodes) end end diff --git a/app/graphql/resolvers/concerns/resolves_groups.rb b/app/graphql/resolvers/concerns/resolves_groups.rb index 2a3dce80057..1268e74fd58 100644 --- a/app/graphql/resolvers/concerns/resolves_groups.rb +++ b/app/graphql/resolvers/concerns/resolves_groups.rb @@ -22,6 +22,7 @@ module ResolvesGroups custom_emoji: [:custom_emoji], full_path: [:route], path: [:route], + web_url: [:route], dependency_proxy_blob_count: [:dependency_proxy_blobs], dependency_proxy_blobs: [:dependency_proxy_blobs], dependency_proxy_image_count: [:dependency_proxy_manifests], diff --git a/app/graphql/resolvers/environments/nested_environments_resolver.rb b/app/graphql/resolvers/environments/nested_environments_resolver.rb new file mode 100644 index 00000000000..f043270beca --- /dev/null +++ b/app/graphql/resolvers/environments/nested_environments_resolver.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Resolvers + module Environments + class NestedEnvironmentsResolver < EnvironmentsResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::NestedEnvironmentType, null: true + + authorizes_object! + authorize :read_environment + + def resolve(**args) + offset_pagination(super(**args).nested) + end + end + end +end diff --git a/app/graphql/resolvers/environments_resolver.rb b/app/graphql/resolvers/environments_resolver.rb index f265e2183d0..aca1a36f0f5 100644 --- a/app/graphql/resolvers/environments_resolver.rb +++ b/app/graphql/resolvers/environments_resolver.rb @@ -14,6 +14,10 @@ module Resolvers required: false, description: 'States of environments that should be included in result.' + argument :type, GraphQL::Types::String, + required: false, + description: 'Search query for environment type.' + type Types::EnvironmentType, null: true alias_method :project, :object diff --git a/app/graphql/resolvers/group_packages_resolver.rb b/app/graphql/resolvers/group_packages_resolver.rb index e6a6abb39dd..ae578390fd5 100644 --- a/app/graphql/resolvers/group_packages_resolver.rb +++ b/app/graphql/resolvers/group_packages_resolver.rb @@ -13,9 +13,9 @@ module Resolvers default_value: :created_desc GROUP_SORT_TO_PARAMS_MAP = SORT_TO_PARAMS_MAP.merge({ - project_path_desc: { order_by: 'project_path', sort: 'desc' }, - project_path_asc: { order_by: 'project_path', sort: 'asc' } - }).freeze + project_path_desc: { order_by: 'project_path', sort: 'desc' }, + project_path_asc: { order_by: 'project_path', sort: 'asc' } + }).freeze def resolve(sort:, **filters) return unless packages_available? diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index e3102a7d32a..3e61ba755d8 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -12,6 +12,11 @@ module Resolvers # see app/graphql/types/issue_connection.rb type 'Types::IssueConnection', null: true + before_connection_authorization do |nodes, current_user| + projects = nodes.map(&:project) + ::Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects, current_user).execute + end + def resolve_with_lookahead(**args) return unless Feature.enabled?(:root_level_issues_query) diff --git a/app/graphql/resolvers/package_details_resolver.rb b/app/graphql/resolvers/package_details_resolver.rb index b77c6b1112b..c565fcb70e3 100644 --- a/app/graphql/resolvers/package_details_resolver.rb +++ b/app/graphql/resolvers/package_details_resolver.rb @@ -11,6 +11,14 @@ module Resolvers description: 'Global ID of the package.' def resolve(id:) + Gitlab::Graphql::Lazy.with_value(find_object(id: id)) do |package| + package if package.default? + end + end + + private + + def find_object(id:) GitlabSchema.find_by_gid(id) end end diff --git a/app/graphql/resolvers/package_pipelines_resolver.rb b/app/graphql/resolvers/package_pipelines_resolver.rb index 9ff77f02547..7f610915489 100644 --- a/app/graphql/resolvers/package_pipelines_resolver.rb +++ b/app/graphql/resolvers/package_pipelines_resolver.rb @@ -18,7 +18,7 @@ module Resolvers # This returns a promise for a connection of promises for pipelines: # Lazy[Connection[Lazy[Pipeline]]] structure - def resolve(first: nil, last: nil, after: nil, before: nil, lookahead:) + def resolve(lookahead:, first: nil, last: nil, after: nil, before: nil) default_value = default_value_for(first: first, last: last, after: after, before: before) BatchLoader::GraphQL.for(package.id) .batch(default_value: default_value) do |package_ids, loader| diff --git a/app/graphql/resolvers/paginated_tree_resolver.rb b/app/graphql/resolvers/paginated_tree_resolver.rb index c7e9e522c25..6c4e978125e 100644 --- a/app/graphql/resolvers/paginated_tree_resolver.rb +++ b/app/graphql/resolvers/paginated_tree_resolver.rb @@ -41,7 +41,10 @@ module Resolvers next_cursor = tree.cursor&.next_cursor Gitlab::Graphql::ExternallyPaginatedArray.new(cursor, next_cursor, *tree) rescue Gitlab::Git::CommandError => e - raise Gitlab::Graphql::Errors::ArgumentError, e + raise Gitlab::Graphql::Errors::BaseError.new( + e, + extensions: { code: e.code, gitaly_code: e.status, service: e.service } + ) end def self.field_options diff --git a/app/graphql/resolvers/project_jobs_resolver.rb b/app/graphql/resolvers/project_jobs_resolver.rb index 4d13a4a3fae..2bf71679dbf 100644 --- a/app/graphql/resolvers/project_jobs_resolver.rb +++ b/app/graphql/resolvers/project_jobs_resolver.rb @@ -14,10 +14,18 @@ module Resolvers required: false, description: 'Filter jobs by status.' + argument :with_artifacts, ::GraphQL::Types::Boolean, + required: false, + description: 'Filter by artifacts presence.' + alias_method :project, :object - def resolve_with_lookahead(statuses: nil) - jobs = ::Ci::JobsFinder.new(current_user: current_user, project: project, params: { scope: statuses }).execute + def resolve_with_lookahead(statuses: nil, with_artifacts: nil) + jobs = ::Ci::JobsFinder.new( + current_user: current_user, project: project, params: { + scope: statuses, with_artifacts: with_artifacts + } + ).execute apply_lookahead(jobs) end @@ -26,7 +34,7 @@ module Resolvers def preloads { - previous_stage_jobs_and_needs: [:needs, :pipeline], + previous_stage_jobs_or_needs: [:needs, :pipeline], artifacts: [:job_artifacts], pipeline: [:user] } diff --git a/app/graphql/resolvers/projects/fork_details_resolver.rb b/app/graphql/resolvers/projects/fork_details_resolver.rb new file mode 100644 index 00000000000..fcc13a1bc1e --- /dev/null +++ b/app/graphql/resolvers/projects/fork_details_resolver.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Resolvers + module Projects + class ForkDetailsResolver < BaseResolver + type Types::Projects::ForkDetailsType, null: true + + argument :ref, GraphQL::Types::String, + required: false, + description: 'Ref of the fork. Default value is HEAD.' + + alias_method :project, :object + + def resolve(**args) + return unless project.forked? + + ::Projects::Forks::DivergenceCounts.new(project, args[:ref]).counts + end + end + end +end diff --git a/app/graphql/resolvers/work_items/work_item_discussions_resolver.rb b/app/graphql/resolvers/work_items/work_item_discussions_resolver.rb new file mode 100644 index 00000000000..b40d85e8003 --- /dev/null +++ b/app/graphql/resolvers/work_items/work_item_discussions_resolver.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Resolvers + module WorkItems + class WorkItemDiscussionsResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + extension Gitlab::Graphql::Extensions::ForwardOnlyExternallyPaginatedArrayExtension + + authorize :read_work_item + authorizes_object! + + # this resolver may be calling gitaly as part of parsing notes that contain commit references + calls_gitaly! + + alias_method :notes_widget, :object + + argument :filter, Types::WorkItems::NotesFilterTypeEnum, + required: false, + default_value: Types::WorkItems::NotesFilterTypeEnum.default_value, + description: 'Type of notes collection: ALL_NOTES, ONLY_COMMENTS, ONLY_ACTIVITY.' + + type Types::Notes::DiscussionType.connection_type, null: true + + def resolve(**args) + finder = Issuable::DiscussionsListService.new(current_user, work_item, params(args)) + + Gitlab::Graphql::ExternallyPaginatedArray.new( + finder.paginator.cursor_for_previous_page, + finder.paginator.cursor_for_next_page, + *finder.execute + ) + end + + def self.field_options + # we manage the pagination manually through external array, so opt out of the connection field extension + super.merge(connection: false) + end + + def self.calculate_ext_conn_complexity + true + end + + def self.complexity_multiplier(args) + 0.05 + end + + private + + def work_item + notes_widget.work_item + end + strong_memoize_attr :work_item + + def params(args) + { + notes_filter: args[:filter], + cursor: args[:after], + per_page: self.class.nodes_limit(args, @field, context: context) + } + end + + def self.nodes_limit(args, field, **kwargs) + page_size = field&.max_page_size || kwargs[:context]&.schema&.default_max_page_size + [args[:first], page_size].compact.min + end + end + end +end diff --git a/app/graphql/resolvers/work_items_resolver.rb b/app/graphql/resolvers/work_items_resolver.rb index 42f4f99d4a9..a3de875c196 100644 --- a/app/graphql/resolvers/work_items_resolver.rb +++ b/app/graphql/resolvers/work_items_resolver.rb @@ -55,6 +55,7 @@ module Resolvers last_edited_by: :last_edited_by, assignees: :assignees, parent: :work_item_parent, + children: { work_item_children: [:author, { project: :project_feature }] }, labels: :labels, milestone: :milestone } diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb index a0d19229d3d..a13453f9194 100644 --- a/app/graphql/types/alert_management/alert_type.rb +++ b/app/graphql/types/alert_management/alert_type.rb @@ -13,6 +13,11 @@ module Types authorize :read_alert_management_alert + field :id, + GraphQL::Types::ID, + null: false, + description: 'ID of the alert.' + field :iid, GraphQL::Types::ID, null: false, @@ -116,7 +121,10 @@ module Types null: true, description: 'Runbook for the alert as defined in alert details.' - field :todos, description: 'To-do items of the current user for the alert.', resolver: Resolvers::TodosResolver + field :todos, + Types::TodoType.connection_type, + description: 'To-do items of the current user for the alert.', + resolver: Resolvers::TodosResolver field :details_url, GraphQL::Types::String, diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb index 36ba3399754..615c143a0b9 100644 --- a/app/graphql/types/base_field.rb +++ b/app/graphql/types/base_field.rb @@ -135,15 +135,16 @@ module Types :resolver_complexity, args, child_complexity: child_complexity ).to_i complexity += 1 if calls_gitaly? - complexity += complexity * connection_complexity_multiplier(ctx, args) + ext_conn = resolver&.try(:calculate_ext_conn_complexity) + complexity += complexity * connection_complexity_multiplier(ctx, args, calculate_ext_conn_complexity: ext_conn) complexity.to_i end end - def connection_complexity_multiplier(ctx, args) + def connection_complexity_multiplier(ctx, args, calculate_ext_conn_complexity:) # Resolvers may add extra complexity depending on number of items being loaded. - return 0 unless connection? + return 0 if !connection? && !calculate_ext_conn_complexity page_size = max_page_size || ctx.schema.default_max_page_size limit_value = [args[:first], args[:last], page_size].compact.min diff --git a/app/graphql/types/ci/config_variable_type.rb b/app/graphql/types/ci/config_variable_type.rb index 5b5890fd5a5..020af5b2444 100644 --- a/app/graphql/types/ci/config_variable_type.rb +++ b/app/graphql/types/ci/config_variable_type.rb @@ -19,6 +19,7 @@ module Types description: 'Value of the variable.' field :value_options, [GraphQL::Types::String], + hash_key: :options, null: true, description: 'Value options for the variable.' end diff --git a/app/graphql/types/ci/freeze_period_status_enum.rb b/app/graphql/types/ci/freeze_period_status_enum.rb new file mode 100644 index 00000000000..aebd0f537e9 --- /dev/null +++ b/app/graphql/types/ci/freeze_period_status_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Ci + class FreezePeriodStatusEnum < BaseEnum + graphql_name 'CiFreezePeriodStatus' + description 'Deploy freeze period status' + + value 'ACTIVE', value: :active, description: 'Freeze period is active.' + value 'INACTIVE', value: :inactive, description: 'Freeze period is inactive.' + end + end +end diff --git a/app/graphql/types/ci/freeze_period_type.rb b/app/graphql/types/ci/freeze_period_type.rb new file mode 100644 index 00000000000..6a3f2ed8fa4 --- /dev/null +++ b/app/graphql/types/ci/freeze_period_type.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Types + module Ci + class FreezePeriodType < BaseObject + graphql_name 'CiFreezePeriod' + description 'Represents a deployment freeze window of a project' + + authorize :read_freeze_period + + present_using ::Ci::FreezePeriodPresenter + + field :status, Types::Ci::FreezePeriodStatusEnum, + description: 'Freeze period status.', + null: false + + field :start_cron, GraphQL::Types::String, + description: 'Start of the freeze period in cron format.', + null: false, + method: :freeze_start + + field :end_cron, GraphQL::Types::String, + description: 'End of the freeze period in cron format.', + null: false, + method: :freeze_end + + field :cron_timezone, GraphQL::Types::String, + description: 'Time zone for the cron fields, defaults to UTC if not provided.', + null: true + + field :start_time, Types::TimeType, + description: 'Timestamp (UTC) of when the current/next active period starts.', + null: true + + field :end_time, Types::TimeType, + description: 'Timestamp (UTC) of when the current/next active period ends.', + null: true, + method: :time_end_from_now + end + end +end diff --git a/app/graphql/types/ci/pipeline_schedule_type.rb b/app/graphql/types/ci/pipeline_schedule_type.rb index 04f9fc78a92..904fa3f1c72 100644 --- a/app/graphql/types/ci/pipeline_schedule_type.rb +++ b/app/graphql/types/ci/pipeline_schedule_type.rb @@ -5,6 +5,8 @@ module Types class PipelineScheduleType < BaseObject graphql_name 'PipelineSchedule' + description 'Represents a pipeline schedule' + connection_type_class(Types::CountableConnectionType) expose_permissions Types::PermissionTypes::Ci::PipelineSchedules @@ -17,7 +19,9 @@ module Types field :owner, ::Types::UserType, null: false, description: 'Owner of the pipeline schedule.' - field :active, GraphQL::Types::Boolean, null: false, description: 'Indicates if a pipeline schedule is active.' + field :active, GraphQL::Types::Boolean, null: false, description: 'Indicates if the pipeline schedule is active.' + + field :project, ::Types::ProjectType, null: true, description: 'Project of the pipeline schedule.' field :next_run_at, Types::TimeType, null: false, description: 'Time when the next pipeline will run.' @@ -26,20 +30,50 @@ module Types field :last_pipeline, PipelineType, null: true, description: 'Last pipeline object.' field :ref_for_display, GraphQL::Types::String, - null: true, description: 'Git ref for the pipeline schedule.', method: :ref_for_display - - field :ref_path, GraphQL::Types::String, null: true, description: 'Path to the ref that triggered the pipeline.' + null: true, description: 'Git ref for the pipeline schedule.' field :for_tag, GraphQL::Types::Boolean, null: false, description: 'Indicates if a pipelines schedule belongs to a tag.', method: :for_tag? - field :cron, GraphQL::Types::String, null: false, description: 'Cron notation for the schedule.' + field :edit_path, GraphQL::Types::String, + null: true, + description: 'Edit path of the pipeline schedule.', + authorize: :update_pipeline_schedule + + field :variables, + Types::Ci::PipelineScheduleVariableType.connection_type, + null: true, + description: 'Pipeline schedule variables.', + authorize: :read_pipeline_schedule_variables + + field :ref, GraphQL::Types::String, + null: true, description: 'Ref of the pipeline schedule.', method: :ref_for_display + + field :ref_path, GraphQL::Types::String, + null: true, + description: 'Path to the ref that triggered the pipeline.' - field :cron_timezone, GraphQL::Types::String, null: false, description: 'Timezone for the pipeline schedule.' + field :cron, GraphQL::Types::String, + null: false, + description: 'Cron notation for the schedule.' + + field :cron_timezone, GraphQL::Types::String, + null: false, + description: 'Timezone for the pipeline schedule.' + + field :created_at, Types::TimeType, + null: false, description: 'Timestamp of when the pipeline schedule was created.' + + field :updated_at, Types::TimeType, + null: false, description: 'Timestamp of when the pipeline schedule was last updated.' def ref_path ::Gitlab::Routing.url_helpers.project_commits_path(object.project, object.ref_for_display) end + + def edit_path + ::Gitlab::Routing.url_helpers.edit_project_pipeline_schedule_path(object.project, object) + end end end end diff --git a/app/graphql/types/ci/pipeline_schedule_variable_type.rb b/app/graphql/types/ci/pipeline_schedule_variable_type.rb new file mode 100644 index 00000000000..1cb407bc2e4 --- /dev/null +++ b/app/graphql/types/ci/pipeline_schedule_variable_type.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Ci + class PipelineScheduleVariableType < BaseObject + graphql_name 'PipelineScheduleVariable' + + authorize :read_pipeline_schedule_variables + + implements(VariableInterface) + end + end +end diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb index 4a523f2edd9..cb561f48b3b 100644 --- a/app/graphql/types/ci/pipeline_type.rb +++ b/app/graphql/types/ci/pipeline_type.rb @@ -78,7 +78,7 @@ module Types resolver: Resolvers::Ci::PipelineStagesResolver field :user, - type: Types::UserType, + type: 'Types::UserType', null: true, description: 'Pipeline user.' diff --git a/app/graphql/types/ci/runner_job_execution_status_enum.rb b/app/graphql/types/ci/runner_job_execution_status_enum.rb new file mode 100644 index 00000000000..686ea085199 --- /dev/null +++ b/app/graphql/types/ci/runner_job_execution_status_enum.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module Ci + class RunnerJobExecutionStatusEnum < BaseEnum + graphql_name 'CiRunnerJobExecutionStatus' + + value 'IDLE', + description: "Runner is idle.", + value: :idle, + deprecated: { milestone: '15.7', reason: :alpha } + + value 'RUNNING', + description: 'Runner is executing jobs.', + value: :running, + deprecated: { milestone: '15.7', reason: :alpha } + end + end +end diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb index a9c76974850..5d34906f7b8 100644 --- a/app/graphql/types/ci/runner_type.rb +++ b/app/graphql/types/ci/runner_type.rb @@ -23,6 +23,9 @@ module Types deprecated: { reason: 'Use paused', milestone: '14.8' } field :admin_url, GraphQL::Types::String, null: true, description: 'Admin URL of the runner. Only available for administrators.' + field :architecture_name, GraphQL::Types::String, null: true, + description: 'Architecture provided by the the runner.', + method: :architecture field :contacted_at, Types::TimeType, null: true, description: 'Timestamp of last contact from this runner.', method: :contacted_at @@ -35,32 +38,39 @@ module Types field :executor_name, GraphQL::Types::String, null: true, description: 'Executor last advertised by the runner.', method: :executor_name - field :platform_name, GraphQL::Types::String, null: true, - description: 'Platform provided by the runner.', - method: :platform - field :architecture_name, GraphQL::Types::String, null: true, - description: 'Architecture provided by the the runner.', - method: :architecture - field :maintenance_note, GraphQL::Types::String, null: true, - description: 'Runner\'s maintenance notes.' - field :groups, ::Types::GroupType.connection_type, null: true, - description: 'Groups the runner is associated with. For group runners only.' + field :groups, 'Types::GroupConnection', + null: true, + resolver: ::Resolvers::Ci::RunnerGroupsResolver, + description: 'Groups the runner is associated with. For group runners only.' field :id, ::Types::GlobalIDType[::Ci::Runner], null: false, description: 'ID of the runner.' field :ip_address, GraphQL::Types::String, null: true, description: 'IP address of the runner.' field :job_count, GraphQL::Types::Int, null: true, description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to indicate that more items exist)." + field :job_execution_status, + Types::Ci::RunnerJobExecutionStatusEnum, + null: true, + description: 'Job execution status of the runner.', + deprecated: { milestone: '15.7', reason: :alpha } field :jobs, ::Types::Ci::JobType.connection_type, null: true, description: 'Jobs assigned to the runner. This field can only be resolved for one runner in any single request.', authorize: :read_builds, resolver: ::Resolvers::Ci::RunnerJobsResolver field :locked, GraphQL::Types::Boolean, null: true, description: 'Indicates the runner is locked.' + field :maintenance_note, GraphQL::Types::String, null: true, + description: 'Runner\'s maintenance notes.' field :maximum_timeout, GraphQL::Types::Int, null: true, description: 'Maximum timeout (in seconds) for jobs processed by the runner.' + field :owner_project, ::Types::ProjectType, null: true, + description: 'Project that owns the runner. For project runners only.', + resolver: ::Resolvers::Ci::RunnerOwnerProjectResolver field :paused, GraphQL::Types::Boolean, null: false, description: 'Indicates the runner is paused and not available to run jobs.' + field :platform_name, GraphQL::Types::String, null: true, + description: 'Platform provided by the runner.', + method: :platform field :project_count, GraphQL::Types::Int, null: true, description: 'Number of projects that the runner is associated with.' field :projects, @@ -88,9 +98,6 @@ module Types method: :token_expires_at field :version, GraphQL::Types::String, null: true, description: 'Version of the runner.' - field :owner_project, ::Types::ProjectType, null: true, - description: 'Project that owns the runner. For project runners only.', - resolver: ::Resolvers::Ci::RunnerOwnerProjectResolver markdown_field :maintenance_note_html, null: true @@ -99,8 +106,25 @@ module Types end def job_count - # We limit to 1 above the JOB_COUNT_LIMIT to indicate that more items exist after JOB_COUNT_LIMIT - runner.builds.limit(JOB_COUNT_LIMIT + 1).count + BatchLoader::GraphQL.for(runner.id).batch(key: :job_count) do |runner_ids, loader, _args| + # rubocop: disable CodeReuse/ActiveRecord + # We limit to 1 above the JOB_COUNT_LIMIT to indicate that more items exist after JOB_COUNT_LIMIT + builds_tbl = ::Ci::Build.arel_table + runners_tbl = ::Ci::Runner.arel_table + lateral_query = ::Ci::Build.select(1) + .where(builds_tbl['runner_id'].eq(runners_tbl['id'])) + .limit(JOB_COUNT_LIMIT + 1) + counts = ::Ci::Runner.joins("JOIN LATERAL (#{lateral_query.to_sql}) builds_with_limit ON true") + .id_in(runner_ids) + .select(:id, Arel.star.count.as('count')) + .group(:id) + .index_by(&:id) + # rubocop: enable CodeReuse/ActiveRecord + + runner_ids.each do |runner_id| + loader.call(runner_id, counts[runner_id]&.count || 0) + end + end end def admin_url @@ -111,14 +135,13 @@ module Types Gitlab::Routing.url_helpers.edit_admin_runner_url(runner) if can_admin_runners? end - # rubocop: disable CodeReuse/ActiveRecord def project_count BatchLoader::GraphQL.for(runner.id).batch(key: :runner_project_count) do |ids, loader, args| counts = ::Ci::Runner.project_type .select(:id, 'COUNT(ci_runner_projects.id) as count') .left_outer_joins(:runner_projects) - .where(id: ids) - .group(:id) + .id_in(ids) + .group(:id) # rubocop: disable CodeReuse/ActiveRecord .index_by(&:id) ids.each do |id| @@ -126,12 +149,15 @@ module Types end end end - # rubocop: enable CodeReuse/ActiveRecord - def groups - return unless runner.group_type? + def job_execution_status + BatchLoader::GraphQL.for(runner.id).batch(key: :running_builds_exist) do |runner_ids, loader| + statuses = ::Ci::Runner.id_in(runner_ids).with_running_builds.index_by(&:id) - batched_owners(::Ci::RunnerNamespace, Group, :runner_groups, :namespace_id) + runner_ids.each do |runner_id| + loader.call(runner_id, statuses[runner_id] ? :running : :idle) + end + end end private @@ -139,29 +165,6 @@ module Types def can_admin_runners? context[:current_user]&.can_admin_all_resources? end - - # rubocop: disable CodeReuse/ActiveRecord - def batched_owners(runner_assoc_type, assoc_type, key, column_name) - BatchLoader::GraphQL.for(runner.id).batch(key: key) do |runner_ids, loader| - plucked_runner_and_owner_ids = runner_assoc_type - .select(:runner_id, column_name) - .where(runner_id: runner_ids) - .pluck(:runner_id, column_name) - # In plucked_runner_and_owner_ids, first() represents the runner ID, and second() the owner ID, - # so let's group the owner IDs by runner ID - runner_owner_ids_by_runner_id = plucked_runner_and_owner_ids - .group_by(&:first) - .transform_values { |runner_and_owner_id| runner_and_owner_id.map(&:second) } - - owner_ids = runner_owner_ids_by_runner_id.values.flatten.uniq - owners = assoc_type.where(id: owner_ids).index_by(&:id) - - runner_ids.each do |runner_id| - loader.call(runner_id, runner_owner_ids_by_runner_id[runner_id]&.map { |owner_id| owners[owner_id] } || []) - end - end - end - # rubocop: enable CodeReuse/ActiveRecord end end end diff --git a/app/graphql/types/commit_signature_interface.rb b/app/graphql/types/commit_signature_interface.rb index 6b0c16e538a..0449a0634ef 100644 --- a/app/graphql/types/commit_signature_interface.rb +++ b/app/graphql/types/commit_signature_interface.rb @@ -21,7 +21,8 @@ module Types description: 'Project of the associated commit.' orphan_types Types::CommitSignatures::GpgSignatureType, - Types::CommitSignatures::X509SignatureType + Types::CommitSignatures::X509SignatureType, + Types::CommitSignatures::SshSignatureType def self.resolve_type(object, context) case object @@ -29,6 +30,8 @@ module Types Types::CommitSignatures::GpgSignatureType when ::CommitSignatures::X509CommitSignature Types::CommitSignatures::X509SignatureType + when ::CommitSignatures::SshSignature + Types::CommitSignatures::SshSignatureType else raise 'Unsupported commit signature type' end diff --git a/app/graphql/types/commit_signatures/gpg_signature_type.rb b/app/graphql/types/commit_signatures/gpg_signature_type.rb index 2a845fff3e2..3baf2d9d21d 100644 --- a/app/graphql/types/commit_signatures/gpg_signature_type.rb +++ b/app/graphql/types/commit_signatures/gpg_signature_type.rb @@ -11,6 +11,7 @@ module Types authorize :download_code field :user, Types::UserType, null: true, + method: :signed_by_user, description: 'User associated with the key.' field :gpg_key_user_name, GraphQL::Types::String, diff --git a/app/graphql/types/commit_signatures/ssh_signature_type.rb b/app/graphql/types/commit_signatures/ssh_signature_type.rb new file mode 100644 index 00000000000..92eb4f7949a --- /dev/null +++ b/app/graphql/types/commit_signatures/ssh_signature_type.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + module CommitSignatures + class SshSignatureType < Types::BaseObject + graphql_name 'SshSignature' + description 'SSH signature for a signed commit' + + implements Types::CommitSignatureInterface + + authorize :download_code + + field :user, Types::UserType, null: true, + method: :signed_by_user, + calls_gitaly: true, + description: 'User associated with the key.' + + field :key, Types::KeyType, + null: true, + description: 'SSH key used for the signature.' + end + end +end diff --git a/app/graphql/types/commit_signatures/x509_signature_type.rb b/app/graphql/types/commit_signatures/x509_signature_type.rb index 9ac96dbc015..2d58c3d5b5d 100644 --- a/app/graphql/types/commit_signatures/x509_signature_type.rb +++ b/app/graphql/types/commit_signatures/x509_signature_type.rb @@ -11,6 +11,7 @@ module Types authorize :download_code field :user, Types::UserType, null: true, + method: :signed_by_user, calls_gitaly: true, description: 'User associated with the key.' diff --git a/app/graphql/types/container_repository_type.rb b/app/graphql/types/container_repository_type.rb index cb818ac5e92..dfa599e798c 100644 --- a/app/graphql/types/container_repository_type.rb +++ b/app/graphql/types/container_repository_type.rb @@ -13,6 +13,7 @@ module Types field :expiration_policy_cleanup_status, Types::ContainerRepositoryCleanupStatusEnum, null: true, description: 'Tags cleanup status for the container repository.' field :expiration_policy_started_at, Types::TimeType, null: true, description: 'Timestamp when the cleanup done by the expiration policy was started on the container repository.' field :id, GraphQL::Types::ID, null: false, description: 'ID of the container repository.' + field :last_cleanup_deleted_tags_count, GraphQL::Types::Int, null: true, description: 'Number of deleted tags from the last cleanup.' field :location, GraphQL::Types::String, null: false, description: 'URL of the container repository.' field :migration_state, GraphQL::Types::String, null: false, description: 'Migration state of the container repository.' field :name, GraphQL::Types::String, null: false, description: 'Name of the container repository.' @@ -21,7 +22,6 @@ module Types field :status, Types::ContainerRepositoryStatusEnum, null: true, description: 'Status of the container repository.' field :tags_count, GraphQL::Types::Int, null: false, description: 'Number of tags associated with this image.' field :updated_at, Types::TimeType, null: false, description: 'Timestamp when the container repository was updated.' - field :last_cleanup_deleted_tags_count, GraphQL::Types::Int, null: true, description: 'Number of deleted tags from the last cleanup.' def can_delete Ability.allowed?(current_user, :update_container_image, object) diff --git a/app/graphql/types/dependency_proxy/manifest_type.rb b/app/graphql/types/dependency_proxy/manifest_type.rb index f7e751e30d3..53b7610e490 100644 --- a/app/graphql/types/dependency_proxy/manifest_type.rb +++ b/app/graphql/types/dependency_proxy/manifest_type.rb @@ -14,11 +14,11 @@ module Types field :id, ::Types::GlobalIDType[::DependencyProxy::Manifest], null: false, description: 'ID of the manifest.' field :image_name, GraphQL::Types::String, null: false, description: 'Name of the image.' field :size, GraphQL::Types::String, null: false, description: 'Size of the manifest file.' - field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.' field :status, Types::DependencyProxy::ManifestTypeEnum, null: false, description: "Status of the manifest (#{::DependencyProxy::Manifest.statuses.keys.join(', ')})" + field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.' def image_name object.file_name.chomp(File.extname(object.file_name)) diff --git a/app/graphql/types/deployment_details_type.rb b/app/graphql/types/deployment_details_type.rb deleted file mode 100644 index bbb5cc8e3f1..00000000000 --- a/app/graphql/types/deployment_details_type.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module Types - class DeploymentDetailsType < DeploymentType - graphql_name 'DeploymentDetails' - description 'The details of the deployment' - authorize :read_deployment - present_using ::Deployments::DeploymentPresenter - - field :tags, - [Types::DeploymentTagType], - description: 'Git tags that contain this deployment.', - calls_gitaly: true - end -end - -Types::DeploymentDetailsType.prepend_mod_with('Types::DeploymentDetailsType') diff --git a/app/graphql/types/deployment_type.rb b/app/graphql/types/deployment_type.rb index 59b59dc4e1d..1c23fd44ea1 100644 --- a/app/graphql/types/deployment_type.rb +++ b/app/graphql/types/deployment_type.rb @@ -1,12 +1,6 @@ # frozen_string_literal: true module Types - # If you're considering to add a new field in DeploymentType, please follow this guideline: - # - If the field is preloadable in batch, define it in DeploymentType. - # In this case, you should extend DeploymentsResolver logic to preload the field. Also, add a new test that - # fetching the specific field for multiple deployments doesn't cause N+1 query problem. - # - If the field is NOT preloadable in batch, define it in DeploymentDetailsType. - # This type can be only fetched for a single deployment, so you don't need to take care of the preloading. class DeploymentType < BaseObject graphql_name 'Deployment' description 'The deployment of an environment' @@ -15,6 +9,8 @@ module Types authorize :read_deployment + expose_permissions Types::PermissionTypes::Deployment + field :id, GraphQL::Types::ID, description: 'Global ID of the deployment.' @@ -65,5 +61,15 @@ module Types Types::UserType, description: 'User who executed the deployment.', method: :deployed_by + + field :tags, + [Types::DeploymentTagType], + description: 'Git tags that contain this deployment. ' \ + 'This field can only be resolved for one deployment in any single request.', + calls_gitaly: true do + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 + end end end + +Types::DeploymentType.prepend_mod_with('Types::DeploymentType') diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb index dd2286d333d..5f58fc38540 100644 --- a/app/graphql/types/environment_type.rb +++ b/app/graphql/types/environment_type.rb @@ -9,6 +9,12 @@ module Types authorize :read_environment + expose_permissions Types::PermissionTypes::Environment, + description: 'Permissions for the current user on the resource. '\ + 'This field can only be resolved for one environment in any single request.' do + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 + end + field :name, GraphQL::Types::String, null: false, description: 'Human-readable name of the environment.' @@ -67,6 +73,11 @@ module Types description: 'Last deployment of the environment.', resolver: Resolvers::Environments::LastDeploymentResolver + field :deploy_freezes, + [Types::Ci::FreezePeriodType], + null: true, + description: 'Deployment freeze periods of the environment.' + def tier object.tier.to_sym end diff --git a/app/graphql/types/global_id_type.rb b/app/graphql/types/global_id_type.rb index a71c2fb0e6c..7ebd98ff2e7 100644 --- a/app/graphql/types/global_id_type.rb +++ b/app/graphql/types/global_id_type.rb @@ -49,9 +49,7 @@ module Types An example `#{graphql_name}` is: `"#{::Gitlab::GlobalId.build(model_name: model_name, id: 1)}"`. #{ if deprecation = Gitlab::GlobalId::Deprecations.deprecation_by(model_name) - 'The older format `"' + - ::Gitlab::GlobalId.build(model_name: deprecation.old_name, id: 1).to_s + - '"` was deprecated in ' + deprecation.milestone + '.' + "The older format `\"#{::Gitlab::GlobalId.build(model_name: deprecation.old_name, id: 1)}\"` was deprecated in #{deprecation.milestone}." end} MD diff --git a/app/graphql/types/group_connection.rb b/app/graphql/types/group_connection.rb new file mode 100644 index 00000000000..e4332e24302 --- /dev/null +++ b/app/graphql/types/group_connection.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# Normally this wouldn't be needed and we could use +# +# type Types::GroupType.connection_type, null: true +# +# in a resolver. However we can end up with cyclic definitions. +# Running the spec locally can result in errors like +# +# NameError: uninitialized constant Types::GroupType +# +# or other errors. To fix this, we created this file and use +# +# type "Types::GroupConnection", null: true +# +# which gives a delayed resolution, and the proper connection type. +# +# See gitlab/app/graphql/types/ci/runner_type.rb +# Reference: https://github.com/rmosolgo/graphql-ruby/issues/3974#issuecomment-1084444214 +# and https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#testing-tips-and-tricks +# +Types::GroupConnection = Types::GroupType.connection_type diff --git a/app/graphql/types/issue_type_enum.rb b/app/graphql/types/issue_type_enum.rb index 78cd27f60c3..d7f587ff03d 100644 --- a/app/graphql/types/issue_type_enum.rb +++ b/app/graphql/types/issue_type_enum.rb @@ -16,5 +16,9 @@ module Types value 'OBJECTIVE', value: 'objective', description: 'Objective issue type. Available only when feature flag `okrs_mvc` is enabled.', alpha: { milestone: '15.6' } + + value 'KEY_RESULT', value: 'key_result', + description: 'Key Result issue type. Available only when feature flag `okrs_mvc` is enabled.', + alpha: { milestone: '15.7' } end end diff --git a/app/graphql/types/key_type.rb b/app/graphql/types/key_type.rb new file mode 100644 index 00000000000..30699793045 --- /dev/null +++ b/app/graphql/types/key_type.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + class KeyType < Types::BaseObject # rubocop:disable Graphql/AuthorizeTypes + graphql_name 'Key' + description 'Represents an SSH key.' + + field :created_at, Types::TimeType, null: false, + description: 'Timestamp of when the key was created.' + field :expires_at, Types::TimeType, null: false, + description: "Timestamp of when the key expires. It's null if it never expires." + field :id, GraphQL::Types::ID, null: false, description: 'ID of the key.' + field :key, GraphQL::Types::String, null: false, method: :publishable_key, + description: 'Public key of the key pair.' + field :title, GraphQL::Types::String, null: false, description: 'Title of the key.' + end +end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index 49bf7aa638c..abf7b3ad530 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -200,10 +200,10 @@ module Types description: 'Array of available auto merge strategies.' field :commits, Types::CommitType.connection_type, null: true, calls_gitaly: true, description: 'Merge request commits.' - field :committers, Types::UserType.connection_type, null: true, complexity: 5, - calls_gitaly: true, description: 'Users who have added commits to the merge request.' field :commits_without_merge_commits, Types::CommitType.connection_type, null: true, calls_gitaly: true, description: 'Merge request commits excluding merge commits.' + field :committers, Types::UserType.connection_type, null: true, complexity: 5, + calls_gitaly: true, description: 'Users who have added commits to the merge request.' field :has_ci, GraphQL::Types::Boolean, null: false, method: :has_ci?, description: 'Indicates if the merge request has CI.' field :merge_user, Types::UserType, null: true, diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 1cbb2ede544..b342e57804b 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -63,6 +63,8 @@ module Types mount_mutation Mutations::Issues::SetEscalationStatus mount_mutation Mutations::Issues::Update mount_mutation Mutations::Issues::Move + mount_mutation Mutations::Issues::LinkAlerts + mount_mutation Mutations::Issues::UnlinkAlert mount_mutation Mutations::Labels::Create mount_mutation Mutations::MergeRequests::Accept mount_mutation Mutations::MergeRequests::Create @@ -117,6 +119,8 @@ module Types mount_mutation Mutations::Ci::Pipeline::Retry mount_mutation Mutations::Ci::PipelineSchedule::Delete mount_mutation Mutations::Ci::PipelineSchedule::TakeOwnership + mount_mutation Mutations::Ci::PipelineSchedule::Play + mount_mutation Mutations::Ci::PipelineSchedule::Create mount_mutation Mutations::Ci::CiCdSettingsUpdate, deprecated: { reason: :renamed, replacement: 'ProjectCiCdSettingsUpdate', diff --git a/app/graphql/types/nested_environment_type.rb b/app/graphql/types/nested_environment_type.rb new file mode 100644 index 00000000000..b835af2bf45 --- /dev/null +++ b/app/graphql/types/nested_environment_type.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Types + # rubocop: disable Graphql/AuthorizeTypes + class NestedEnvironmentType < BaseObject + graphql_name 'NestedEnvironment' + description 'Describes where code is deployed for a project organized by folder.' + + field :name, GraphQL::Types::String, + null: false, description: 'Human-readable name of the environment.' + + field :size, GraphQL::Types::Int, + null: false, description: 'Number of environments nested in the folder.' + + field :environment, + Types::EnvironmentType, + null: true, description: 'Latest environment in the folder.' + + def environment + BatchLoader::GraphQL.for(object.last_id).batch do |environment_ids, loader| + Environment.id_in(environment_ids).each do |environment| + loader.call(environment.id, environment) + end + end + 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 eef5ce40bde..05629ea9223 100644 --- a/app/graphql/types/notes/note_type.rb +++ b/app/graphql/types/notes/note_type.rb @@ -77,6 +77,14 @@ module Types def author Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find end + + # We now support also SyntheticNote notes as a NoteType, but SyntheticNote does not have a real note ID, + # as SyntheticNote is generated dynamically from a ResourceEvent instance. + def id + return super unless object.is_a?(SyntheticNote) + + ::Gitlab::GlobalId.build(object, model_name: object.class.to_s, id: object.discussion_id) + end end end end diff --git a/app/graphql/types/packages/package_links_type.rb b/app/graphql/types/packages/package_links_type.rb index f16937530b9..eb29fb655bd 100644 --- a/app/graphql/types/packages/package_links_type.rb +++ b/app/graphql/types/packages/package_links_type.rb @@ -12,6 +12,8 @@ module Types field :web_path, GraphQL::Types::String, null: true, description: 'Path to the package details page.' def web_path + return unless object.default? + package_path(object) end end diff --git a/app/graphql/types/permission_types/base_permission_type.rb b/app/graphql/types/permission_types/base_permission_type.rb index 07e6e7a55d6..0192af25d0f 100644 --- a/app/graphql/types/permission_types/base_permission_type.rb +++ b/app/graphql/types/permission_types/base_permission_type.rb @@ -11,20 +11,20 @@ module Types abilities.each { |ability| ability_field(ability) } end - def self.ability_field(ability, **kword_args) + def self.ability_field(ability, **kword_args, &block) define_field_resolver_method(ability) unless resolving_keywords?(kword_args) - permission_field(ability, **kword_args) + permission_field(ability, **kword_args, &block) end - def self.permission_field(name, **kword_args) + def self.permission_field(name, **kword_args, &block) kword_args = kword_args.reverse_merge( name: name, type: GraphQL::Types::Boolean, description: "Indicates the user can perform `#{name}` on this resource", null: false) - field(**kword_args) # rubocop:disable Graphql/Descriptions + field(**kword_args, &block) # rubocop:disable Graphql/Descriptions end def self.define_field_resolver_method(ability) diff --git a/app/graphql/types/permission_types/deployment.rb b/app/graphql/types/permission_types/deployment.rb new file mode 100644 index 00000000000..fce376552b1 --- /dev/null +++ b/app/graphql/types/permission_types/deployment.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module PermissionTypes + class Deployment < BasePermissionType + graphql_name 'DeploymentPermissions' + + abilities :destroy_deployment + ability_field :update_deployment, calls_gitaly: true + end + end +end + +Types::PermissionTypes::Deployment.prepend_mod_with('Types::PermissionTypes::Deployment') diff --git a/app/graphql/types/permission_types/environment.rb b/app/graphql/types/permission_types/environment.rb new file mode 100644 index 00000000000..59c9fce64e5 --- /dev/null +++ b/app/graphql/types/permission_types/environment.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module PermissionTypes + class Environment < BasePermissionType + graphql_name 'EnvironmentPermissions' + + abilities :update_environment, :destroy_environment, :stop_environment + end + end +end diff --git a/app/graphql/types/permission_types/project.rb b/app/graphql/types/permission_types/project.rb index f6a5563d367..c833b512222 100644 --- a/app/graphql/types/permission_types/project.rb +++ b/app/graphql/types/permission_types/project.rb @@ -17,7 +17,8 @@ module Types :admin_wiki, :admin_project, :update_pages, :admin_remote_mirror, :create_label, :update_wiki, :destroy_wiki, :create_pages, :destroy_pages, :read_pages_content, :admin_operations, - :read_merge_request, :read_design, :create_design, :destroy_design + :read_merge_request, :read_design, :create_design, :destroy_design, + :read_environment permission_field :create_snippet diff --git a/app/graphql/types/project_statistics_type.rb b/app/graphql/types/project_statistics_type.rb index c43baf1280b..a1d721856a9 100644 --- a/app/graphql/types/project_statistics_type.rb +++ b/app/graphql/types/project_statistics_type.rb @@ -11,6 +11,10 @@ module Types field :build_artifacts_size, GraphQL::Types::Float, null: false, description: 'Build artifacts size of the project in bytes.' + field :container_registry_size, + GraphQL::Types::Float, + null: true, + description: 'Container Registry size of the project in bytes.' field :lfs_objects_size, GraphQL::Types::Float, null: false, @@ -29,9 +33,5 @@ module Types description: 'Uploads size of the project in bytes.' field :wiki_size, GraphQL::Types::Float, null: true, description: 'Wiki size of the project in bytes.' - field :container_registry_size, - GraphQL::Types::Float, - null: true, - description: 'Container Registry size of the project in bytes.' end end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 771dad00fb3..fe13ee7ef3c 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -258,8 +258,11 @@ module Types field :environments, Types::EnvironmentType.connection_type, null: true, - description: 'Environments of the project.', - resolver: Resolvers::EnvironmentsResolver + description: 'Environments of the project. ' \ + 'This field can only be resolved for one project in any single request.', + resolver: Resolvers::EnvironmentsResolver do + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 + end field :environment, Types::EnvironmentType, @@ -267,8 +270,18 @@ module Types description: 'A single environment of the project.', resolver: Resolvers::EnvironmentsResolver.single + field :nested_environments, + Types::NestedEnvironmentType.connection_type, + null: true, + calls_gitaly: true, + description: 'Environments for this project with nested folders, ' \ + 'can only be resolved for one project in any single request', + resolver: Resolvers::Environments::NestedEnvironmentsResolver do + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 + end + field :deployment, - Types::DeploymentDetailsType, + Types::DeploymentType, null: true, description: 'Details of the deployment of the project.', resolver: Resolvers::DeploymentResolver.single @@ -526,6 +539,13 @@ module Types resolver: Resolvers::Projects::ForkTargetsResolver, description: 'Namespaces in which the current user can fork the project into.' + field :fork_details, Types::Projects::ForkDetailsType, + calls_gitaly: true, + alpha: { milestone: '15.7' }, + authorize: :read_code, + resolver: Resolvers::Projects::ForkDetailsResolver, + description: 'Details of the fork project compared to its upstream project.' + field :branch_rules, Types::Projects::BranchRuleType.connection_type, null: true, @@ -537,6 +557,11 @@ module Types description: "Programming languages used in the project.", calls_gitaly: true + field :runners, Types::Ci::RunnerType.connection_type, + null: true, + resolver: ::Resolvers::Ci::ProjectRunnersResolver, + description: "Find runners visible to the current user." + def timelog_categories object.project_namespace.timelog_categories if Feature.enabled?(:timelog_categories) end diff --git a/app/graphql/types/projects/fork_details_type.rb b/app/graphql/types/projects/fork_details_type.rb new file mode 100644 index 00000000000..88c17d89620 --- /dev/null +++ b/app/graphql/types/projects/fork_details_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module Projects + # rubocop: disable Graphql/AuthorizeTypes + class ForkDetailsType < BaseObject + graphql_name 'ForkDetails' + description 'Details of the fork project compared to its upstream project.' + + field :ahead, GraphQL::Types::Int, + null: true, + description: 'Number of commits ahead of upstream.' + + field :behind, GraphQL::Types::Int, + null: true, + description: 'Number of commits behind upstream.' + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 21cb3f9e06c..7263f792bae 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -67,7 +67,7 @@ module Types end field :package, - description: 'Find a package. This field can only be resolved for one query in any single request.', + description: 'Find a package. This field can only be resolved for one query in any single request. Returns `null` if a package has no `default` status.', resolver: Resolvers::PackageDetailsResolver field :user, Types::UserType, diff --git a/app/graphql/types/release_type.rb b/app/graphql/types/release_type.rb index a20e53ad1bd..8516256b433 100644 --- a/app/graphql/types/release_type.rb +++ b/app/graphql/types/release_type.rb @@ -13,9 +13,6 @@ module Types present_using ReleasePresenter - field :id, ::Types::GlobalIDType[Release], - null: false, - description: 'Global ID of the release.' field :assets, Types::ReleaseAssetsType, null: true, method: :itself, description: 'Assets of the release.' field :created_at, Types::TimeType, null: true, @@ -26,6 +23,11 @@ module Types description: 'Description (also known as "release notes") of the release.' field :evidences, Types::EvidenceType.connection_type, null: true, description: 'Evidence for the release.' + field :historical_release, GraphQL::Types::Boolean, null: true, method: :historical_release?, + description: 'Indicates the release is an historical release.' + field :id, ::Types::GlobalIDType[Release], + null: false, + description: 'Global ID of the release.' field :links, Types::ReleaseLinksType, null: true, method: :itself, description: 'Links of the release.' field :milestones, Types::MilestoneType.connection_type, null: true, @@ -42,8 +44,6 @@ module Types authorize: :read_code field :upcoming_release, GraphQL::Types::Boolean, null: true, method: :upcoming_release?, description: 'Indicates the release is an upcoming release.' - field :historical_release, GraphQL::Types::Boolean, null: true, method: :historical_release?, - description: 'Indicates the release is an historical release.' field :author, Types::UserType, null: true, description: 'User that created the release.' diff --git a/app/graphql/types/root_storage_statistics_type.rb b/app/graphql/types/root_storage_statistics_type.rb index b1b712aab38..64aaf3e73a0 100644 --- a/app/graphql/types/root_storage_statistics_type.rb +++ b/app/graphql/types/root_storage_statistics_type.rb @@ -7,6 +7,7 @@ module Types authorize :read_statistics field :build_artifacts_size, GraphQL::Types::Float, null: false, description: 'CI artifacts size in bytes.' + field :container_registry_size, GraphQL::Types::Float, null: false, description: 'Container Registry size in bytes.' field :dependency_proxy_size, GraphQL::Types::Float, null: false, description: 'Dependency Proxy sizes in bytes.' field :lfs_objects_size, GraphQL::Types::Float, null: false, description: 'LFS objects size in bytes.' field :packages_size, GraphQL::Types::Float, null: false, description: 'Packages size in bytes.' @@ -16,6 +17,5 @@ module Types field :storage_size, GraphQL::Types::Float, null: false, description: 'Total storage in bytes.' field :uploads_size, GraphQL::Types::Float, null: false, description: 'Uploads size in bytes.' field :wiki_size, GraphQL::Types::Float, null: false, description: 'Wiki size in bytes.' - field :container_registry_size, GraphQL::Types::Float, null: false, description: 'Container Registry size in bytes.' end end diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb index 9d5edec82b2..f7f26ba4c5a 100644 --- a/app/graphql/types/subscription_type.rb +++ b/app/graphql/types/subscription_type.rb @@ -34,6 +34,11 @@ module Types subscription: Subscriptions::IssuableUpdated, null: true, description: 'Triggered when the merge status of a merge request is updated.' + + field :merge_request_approval_state_updated, + subscription: Subscriptions::IssuableUpdated, + null: true, + description: 'Triggered when approval state of a merge request is updated.' end end diff --git a/app/graphql/types/todo_action_enum.rb b/app/graphql/types/todo_action_enum.rb index ef43b6eb464..33e1c4e98a4 100644 --- a/app/graphql/types/todo_action_enum.rb +++ b/app/graphql/types/todo_action_enum.rb @@ -5,11 +5,12 @@ module Types value 'assigned', value: 1, description: 'User was assigned.' value 'mentioned', value: 2, description: 'User was mentioned.' value 'build_failed', value: 3, description: 'Build triggered by the user failed.' - value 'marked', value: 4, description: 'User added a TODO.' + value 'marked', value: 4, description: 'User added a to-do item.' value 'approval_required', value: 5, description: 'User was set as an approver.' value 'unmergeable', value: 6, description: 'Merge request authored by the user could not be merged.' value 'directly_addressed', value: 7, description: 'User was directly addressed.' value 'merge_train_removed', value: 8, description: 'Merge request authored by the user was removed from the merge train.' value 'review_requested', value: 9, description: 'Review was requested from the user.' + value 'member_access_requested', value: 10, description: 'Group access requested from the user.' end end diff --git a/app/graphql/types/todo_type.rb b/app/graphql/types/todo_type.rb index 0de6b1d6f8a..6e5ce35033b 100644 --- a/app/graphql/types/todo_type.rb +++ b/app/graphql/types/todo_type.rb @@ -15,13 +15,11 @@ module Types field :project, Types::ProjectType, description: 'Project this to-do item is associated with.', - null: true, - authorize: :read_project + null: true field :group, 'Types::GroupType', description: 'Group this to-do item is associated with.', - null: true, - authorize: :read_group + null: true field :author, Types::UserType, description: 'Author of this to-do item.', diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb index f49b3eee4f5..51046d09f90 100644 --- a/app/graphql/types/user_interface.rb +++ b/app/graphql/types/user_interface.rb @@ -88,7 +88,10 @@ module Types null: true, description: 'Personal namespace of the user.' - field :todos, resolver: Resolvers::TodosResolver, description: 'To-do items of the user.' + field :todos, + Types::TodoType.connection_type, + description: 'To-do items of the user.', + resolver: Resolvers::TodosResolver # Merge request field: MRs can be authored, assigned, or assigned-for-review: field :authored_merge_requests, diff --git a/app/graphql/types/work_items/notes_filter_type_enum.rb b/app/graphql/types/work_items/notes_filter_type_enum.rb new file mode 100644 index 00000000000..93fb4689f0b --- /dev/null +++ b/app/graphql/types/work_items/notes_filter_type_enum.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module WorkItems + class NotesFilterTypeEnum < BaseEnum + graphql_name 'NotesFilterType' + description 'Work item notes collection type.' + + ::UserPreference::NOTES_FILTERS.each_pair do |key, value| + value key.upcase, + value: value, + description: UserPreference.notes_filters.invert[::UserPreference::NOTES_FILTERS[key]] + end + + def self.default_value + ::UserPreference::NOTES_FILTERS[:all_notes] + end + end + end +end diff --git a/app/graphql/types/work_items/widget_interface.rb b/app/graphql/types/work_items/widget_interface.rb index b85d0a23535..672a78f12e1 100644 --- a/app/graphql/types/work_items/widget_interface.rb +++ b/app/graphql/types/work_items/widget_interface.rb @@ -17,7 +17,8 @@ module Types ::Types::WorkItems::Widgets::LabelsType, ::Types::WorkItems::Widgets::AssigneesType, ::Types::WorkItems::Widgets::StartAndDueDateType, - ::Types::WorkItems::Widgets::MilestoneType + ::Types::WorkItems::Widgets::MilestoneType, + ::Types::WorkItems::Widgets::NotesType ].freeze def self.ce_orphan_types @@ -41,6 +42,8 @@ module Types ::Types::WorkItems::Widgets::StartAndDueDateType when ::WorkItems::Widgets::Milestone ::Types::WorkItems::Widgets::MilestoneType + when ::WorkItems::Widgets::Notes + ::Types::WorkItems::Widgets::NotesType else raise "Unknown GraphQL type for widget #{object}" end diff --git a/app/graphql/types/work_items/widgets/hierarchy_type.rb b/app/graphql/types/work_items/widgets/hierarchy_type.rb index 0ccd8af7dc8..4ec8ec84779 100644 --- a/app/graphql/types/work_items/widgets/hierarchy_type.rb +++ b/app/graphql/types/work_items/widgets/hierarchy_type.rb @@ -20,8 +20,29 @@ module Types null: true, complexity: 5, description: 'Child work items.' + field :has_children, GraphQL::Types::Boolean, + null: false, description: 'Indicates if the work item has children.' + + # rubocop: disable CodeReuse/ActiveRecord + def has_children? + BatchLoader::GraphQL.for(object.work_item.id).batch(default_value: false) do |ids, loader| + links_for_parents = ::WorkItems::ParentLink.for_parents(ids) + .select(:work_item_parent_id) + .group(:work_item_parent_id) + .reorder(nil) + + links_for_parents.each { |link| loader.call(link.work_item_parent_id, true) } + end + end + # rubocop: enable CodeReuse/ActiveRecord + + alias_method :has_children, :has_children? + def children - object.children.inc_relations_for_permission_check + relation = object.children + relation = relation.inc_relations_for_permission_check unless object.children.loaded? + + relation end end # rubocop:enable Graphql/AuthorizeTypes diff --git a/app/graphql/types/work_items/widgets/notes_type.rb b/app/graphql/types/work_items/widgets/notes_type.rb new file mode 100644 index 00000000000..7da2777beee --- /dev/null +++ b/app/graphql/types/work_items/widgets/notes_type.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Types + module WorkItems + module Widgets + # Disabling widget level authorization as it might be too granular + # and we already authorize the parent work item + # rubocop:disable Graphql/AuthorizeTypes + class NotesType < BaseObject + graphql_name 'WorkItemWidgetNotes' + description 'Represents a notes widget' + + implements Types::WorkItems::WidgetInterface + + # This field loads user comments, system notes and resource events as a discussion for an work item, + # raising the complexity considerably. In order to discourage fetching this field as part of fetching + # a list of issues we raise the complexity + field :discussions, Types::Notes::DiscussionType.connection_type, + null: true, + description: "Notes on this work item.", + resolver: Resolvers::WorkItems::WorkItemDiscussionsResolver + end + # rubocop:enable Graphql/AuthorizeTypes + end + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 340f3d45365..c78563a9a5f 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -124,7 +124,7 @@ module ApplicationHelper end def simple_sanitize(str) - sanitize(str, tags: %w(a span)) + sanitize(str, tags: %w[a span]) end def body_data @@ -187,14 +187,14 @@ module ApplicationHelper css_classes << html_class unless html_class.blank? content_tag :time, l(time, format: "%b %d, %Y"), - class: css_classes.join(' '), - title: l(time.to_time.in_time_zone, format: :timeago_tooltip), - datetime: time.to_time.getutc.iso8601, - data: { - toggle: 'tooltip', - placement: placement, - container: 'body' - } + class: css_classes.join(' '), + title: l(time.to_time.in_time_zone, format: :timeago_tooltip), + datetime: time.to_time.getutc.iso8601, + data: { + toggle: 'tooltip', + placement: placement, + container: 'body' + } end def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', exclude_author: false) @@ -234,11 +234,11 @@ module ApplicationHelper end def promo_url - 'https://' + promo_host + "https://#{promo_host}" end def support_url - Gitlab::CurrentSettings.current_application_settings.help_page_support_url.presence || promo_url + '/getting-help/' + Gitlab::CurrentSettings.current_application_settings.help_page_support_url.presence || "#{promo_url}/getting-help/" end def instance_review_permitted? @@ -279,7 +279,19 @@ module ApplicationHelper end def stylesheet_link_tag_defer(path) - stylesheet_link_tag(path, media: "print", crossorigin: ActionController::Base.asset_host ? 'anonymous' : nil) + if startup_css_enabled? + stylesheet_link_tag(path, media: "print", crossorigin: ActionController::Base.asset_host ? 'anonymous' : nil) + else + stylesheet_link_tag(path, crossorigin: ActionController::Base.asset_host ? 'anonymous' : nil) + end + end + + def startup_css_enabled? + !params.has_key?(:no_startup_css) + end + + def use_new_fonts? + Feature.enabled?(:new_fonts, current_user) || request.params.has_key?(:new_fonts) end def outdated_browser? diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 7f13f609353..2b2ac262848 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -216,6 +216,7 @@ module ApplicationSettingsHelper :default_branch_protection, :default_ci_config_path, :default_group_visibility, + :default_preferred_language, :default_project_creation, :default_project_visibility, :default_projects_limit, @@ -287,6 +288,7 @@ module ApplicationSettingsHelper :max_import_size, :max_pages_size, :max_pages_custom_domains_per_project, + :max_terraform_state_size_bytes, :max_yaml_size_bytes, :max_yaml_depth, :metrics_method_call_threshold, @@ -318,7 +320,6 @@ module ApplicationSettingsHelper :require_two_factor_authentication, :restricted_visibility_levels, :rsa_key_restriction, - :send_user_confirmation_email, :session_expire_delay, :shared_runners_enabled, :shared_runners_text, @@ -445,7 +446,8 @@ module ApplicationSettingsHelper :project_runner_token_expiration_interval, :pipeline_limit_per_project_user_sha, :invitation_flow_enforcement, - :can_create_group + :can_create_group, + :bulk_import_enabled ].tap do |settings| next if Gitlab.com? @@ -545,7 +547,6 @@ module ApplicationSettingsHelper settings_path: general_admin_application_settings_path(anchor: 'js-signup-settings'), signup_enabled: @application_setting[:signup_enabled].to_s, require_admin_approval_after_user_signup: @application_setting[:require_admin_approval_after_user_signup].to_s, - send_user_confirmation_email: @application_setting[:send_user_confirmation_email].to_s, email_confirmation_setting: @application_setting[:email_confirmation_setting].to_s, minimum_password_length: @application_setting[:minimum_password_length], minimum_password_length_min: ApplicationSetting::DEFAULT_MINIMUM_PASSWORD_LENGTH, diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index 07152133402..c41b5923d13 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -70,11 +70,9 @@ module AuthHelper end def form_based_provider_with_highest_priority - @form_based_provider_with_highest_priority ||= begin - form_based_provider_priority.each do |provider_regexp| - highest_priority = form_based_providers.find { |provider| provider.match?(provider_regexp) } - break highest_priority unless highest_priority.nil? - end + @form_based_provider_with_highest_priority ||= form_based_provider_priority.each do |provider_regexp| + highest_priority = form_based_providers.find { |provider| provider.match?(provider_regexp) } + break highest_priority unless highest_priority.nil? end end diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index 798bb7b64a4..0fac2cb5fc5 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -91,7 +91,7 @@ module AvatarsHelper title: user_name } - tag(:img, image_options) + tag.img(**image_options) end def user_avatar(options = {}) diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index f08c1a2ff0a..281d5c923d0 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -113,7 +113,7 @@ module BlobHelper end def parent_dir_raw_path - blob_raw_path.rpartition("/").first + "/" + "#{blob_raw_path.rpartition('/').first}/" end # SVGs can contain malicious JavaScript; only include whitelisted @@ -295,7 +295,7 @@ module BlobHelper end def edit_link_tag(link_text, edit_path, common_classes) - link_to link_text, edit_path, class: "#{common_classes}" + link_to link_text, edit_path, class: common_classes end def edit_button_tag(blob, common_classes, text, edit_path, project, ref) diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb index 7b8290ac9ef..424e5920fed 100644 --- a/app/helpers/ci/jobs_helper.rb +++ b/app/helpers/ci/jobs_helper.rb @@ -39,7 +39,7 @@ module Ci def job_statuses statuses = Ci::HasStatus::AVAILABLE_STATUSES - statuses.to_h { |status| [status, status.upcase] } + statuses.index_with(&:upcase) end end end diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb index 0de84c0d61f..8df30ee1f0d 100644 --- a/app/helpers/ci/runners_helper.rb +++ b/app/helpers/ci/runners_helper.rb @@ -96,8 +96,8 @@ module Ci def toggle_shared_runners_settings_data(project) { - is_enabled: "#{project.shared_runners_enabled?}", - is_disabled_and_unoverridable: "#{project.group&.shared_runners_setting == Namespace::SR_DISABLED_AND_UNOVERRIDABLE}", + is_enabled: project.shared_runners_enabled?.to_s, + is_disabled_and_unoverridable: (project.group&.shared_runners_setting == Namespace::SR_DISABLED_AND_UNOVERRIDABLE).to_s, update_path: toggle_shared_runners_project_runners_path(project) } end diff --git a/app/helpers/ci/secure_files_helper.rb b/app/helpers/ci/secure_files_helper.rb index 30b2e12ac3b..fca89ddab1e 100644 --- a/app/helpers/ci/secure_files_helper.rb +++ b/app/helpers/ci/secure_files_helper.rb @@ -4,7 +4,7 @@ module Ci def show_secure_files_setting(project, user) return false if user.nil? - Feature.enabled?(:ci_secure_files, project) && user.can?(:read_secure_files, project) + user.can?(:read_secure_files, project) end end end diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 4493bc2bc6d..53781364af7 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -124,7 +124,7 @@ module CommitsHelper new_project_tag_path: new_project_tag_path(project, ref: commit), email_patches_path: project_commit_path(project, commit, format: :patch), plain_diff_path: project_commit_path(project, commit, format: :diff), - can_revert: "#{can_collaborate && !commit.has_been_reverted?(current_user)}", + can_revert: (can_collaborate && !commit.has_been_reverted?(current_user)).to_s, can_cherry_pick: can_collaborate.to_s, can_tag: can?(current_user, :push_code, project).to_s, can_email_patches: (commit.parents.length < 2).to_s diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index e05adc5cd0e..e0a1697cfa9 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -76,13 +76,11 @@ module DiffHelper def diff_line_content(line) if line.blank? " ".html_safe - else + elsif line.start_with?('+', '-', ' ') # `sub` and substring-ing would destroy HTML-safeness of `line` - if line.start_with?('+', '-', ' ') - line[1, line.length] - else - line - end + line[1, line.length] + else + line end end @@ -227,7 +225,7 @@ module DiffHelper end def conflicts(allow_tree_conflicts: false) - return unless merge_request.cannot_be_merged? + return unless merge_request.cannot_be_merged? && merge_request.source_branch_exists? && merge_request.target_branch_exists? conflicts_service = MergeRequests::Conflicts::ListService.new(merge_request, allow_tree_conflicts: allow_tree_conflicts) # rubocop:disable CodeReuse/ServiceClass diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index 62e66b9a3ea..427cbe18fbf 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -17,8 +17,8 @@ module DropdownsHelper end content_tag_options = { class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.key?(:dropdown_class)}" } - content_tag_options[:data] = options[:dropdown_qa_selector] ? { qa_selector: "#{options[:dropdown_qa_selector]}" } : {} - content_tag_options[:data][:testid] = "#{options[:dropdown_testid]}" if options[:dropdown_testid] + content_tag_options[:data] = options[:dropdown_qa_selector] ? { qa_selector: (options[:dropdown_qa_selector]).to_s } : {} + content_tag_options[:data][:testid] = (options[:dropdown_testid]).to_s if options[:dropdown_testid] dropdown_output << content_tag(:div, content_tag_options) do output = [] @@ -86,7 +86,7 @@ module DropdownsHelper title_output = [] if has_back - title_output << content_tag(:button, class: "dropdown-title-button dropdown-menu-back " + margin_class, aria: { label: "Go back" }, type: "button") do + title_output << content_tag(:button, class: "dropdown-title-button dropdown-menu-back #{margin_class}", aria: { label: "Go back" }, type: "button") do sprite_icon('arrow-left') end end @@ -94,7 +94,7 @@ module DropdownsHelper title_output << content_tag(:span, title, class: margin_class) if has_close - title_output << content_tag(:button, class: "dropdown-title-button dropdown-menu-close " + margin_class, aria: { label: "Close" }, type: "button") do + title_output << content_tag(:button, class: "dropdown-title-button dropdown-menu-close #{margin_class}", aria: { label: "Close" }, type: "button") do sprite_icon('close', size: 16, css_class: 'dropdown-menu-close-icon') end end diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index 54733fa9101..cad39854c0e 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -139,7 +139,7 @@ module EmailsHelper max_domain_length = list_id_max_length - Gitlab.config.gitlab.host.length - project.id.to_s.length - 2 if max_domain_length < 3 - return project.id.to_s + "..." + Gitlab.config.gitlab.host + return "#{project.id}...#{Gitlab.config.gitlab.host}" end if project_path_as_domain.length > max_domain_length @@ -151,7 +151,7 @@ module EmailsHelper project_path_as_domain = project_path_as_domain.slice(0, last_dot_index).concat("..") end - project.id.to_s + "." + project_path_as_domain + "." + Gitlab.config.gitlab.host + "#{project.id}.#{project_path_as_domain}.#{Gitlab.config.gitlab.host}" end def html_header_message diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb index b6997b6fb70..0e64a98c9da 100644 --- a/app/helpers/environment_helper.rb +++ b/app/helpers/environment_helper.rb @@ -72,6 +72,7 @@ module EnvironmentHelper { name: environment.name, id: environment.id, + project_full_path: project.full_path, external_url: environment.external_url, can_update_environment: can?(current_user, :update_environment, environment), can_destroy_environment: can_destroy_environment?(environment), diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index 333237db6a4..5bf4fa2ffcc 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -67,7 +67,7 @@ module EnvironmentsHelper 'external_dashboard_url' => project.metrics_setting_external_dashboard_url, 'custom_metrics_path' => project_prometheus_metrics_path(project), 'validate_query_path' => validate_query_project_prometheus_metrics_path(project), - 'custom_metrics_available' => "#{custom_metrics_available?(project)}", + 'custom_metrics_available' => custom_metrics_available?(project).to_s, 'dashboard_timezone' => project.metrics_setting_dashboard_timezone.to_s.upcase } end @@ -78,8 +78,8 @@ module EnvironmentsHelper { 'metrics_dashboard_base_path' => metrics_dashboard_base_path(environment, project), 'current_environment_name' => environment.name, - 'has_metrics' => "#{environment.has_metrics?}", - 'environment_state' => "#{environment.state}" + 'has_metrics' => environment.has_metrics?.to_s, + 'environment_state' => environment.state.to_s } end diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 087e4838ed9..bef2da495b0 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -92,7 +92,7 @@ module EventsHelper content_tag :li, class: active do link_to request.path, link_opts do - content_tag(:span, ' ' + text) + content_tag(:span, " #{text}") end end end diff --git a/app/helpers/groups/observability_helper.rb b/app/helpers/groups/observability_helper.rb index 6fb6acce386..26caac4ce7f 100644 --- a/app/helpers/groups/observability_helper.rb +++ b/app/helpers/groups/observability_helper.rb @@ -5,15 +5,15 @@ module Groups ACTION_TO_PATH = { 'dashboards' => { path: '/', - title: -> { s_('Dashboards') } + title: -> { _('Dashboards') } }, 'manage' => { path: '/dashboards', - title: -> { s_('Manage Dashboards') } + title: -> { _('Manage Dashboards') } }, 'explore' => { path: '/explore', - title: -> { s_('Explore') } + title: -> { _('Explore') } } }.freeze @@ -22,7 +22,7 @@ module Groups # When running Observability UI in standalone mode (i.e. not backed by Observability Backend) # the group-id is not required. This is mostly used for local dev - base_url = ENV['STANDALONE_OBSERVABILITY_UI'] == 'true' ? observability_url : "#{observability_url}/#{group.id}" + base_url = ENV['STANDALONE_OBSERVABILITY_UI'] == 'true' ? observability_url : "#{observability_url}/-/#{group.id}" sanitized_path = if params[:observability_path] && sanitize(params[:observability_path]) != '' CGI.unescapeHTML(sanitize(params[:observability_path])) diff --git a/app/helpers/groups/settings_helper.rb b/app/helpers/groups/settings_helper.rb index 1b391680996..38300043dd7 100644 --- a/app/helpers/groups/settings_helper.rb +++ b/app/helpers/groups/settings_helper.rb @@ -9,7 +9,7 @@ module Groups remove_form_id: remove_form_id, button_text: _('Remove group'), button_testid: 'remove-group-button', - disabled: group.paid?.to_s, + disabled: group.prevent_delete?.to_s, confirm_danger_message: remove_group_message(group), phrase: group.full_path } diff --git a/app/helpers/hooks_helper.rb b/app/helpers/hooks_helper.rb index 921e30edbaa..63544e28a0e 100644 --- a/app/helpers/hooks_helper.rb +++ b/app/helpers/hooks_helper.rb @@ -10,7 +10,7 @@ module HooksHelper def link_to_test_hook(hook, trigger) path = test_hook_path(hook, trigger) - trigger_human_name = trigger.to_s.tr('_', ' ').camelize + trigger_human_name = integration_webhook_event_human_name(trigger) link_to path, rel: 'nofollow', method: :post do content_tag(:span, trigger_human_name) diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index c81041c2d9c..021b47ceab2 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -38,7 +38,7 @@ module IconsHelper css_classes = [] css_classes << "s#{size}" if size - css_classes << "#{css_class}" unless css_class.blank? + css_classes << css_class.to_s unless css_class.blank? content_tag( :svg, diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index 34f4749c42a..0e81cea8ac7 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -7,7 +7,10 @@ module IdeHelper 'use-new-web-ide' => use_new_web_ide?.to_s, 'new-web-ide-help-page-path' => help_page_path('user/project/web_ide/index.md', anchor: 'vscode-reimplementation'), 'user-preferences-path' => profile_preferences_path, - 'branch-name' => @branch + 'branch-name' => @branch, + 'file-path' => @path, + 'fork-info' => @fork_info&.to_json, + 'merge-request' => @merge_request }.merge(use_new_web_ide? ? new_ide_data : legacy_ide_data) end @@ -24,7 +27,9 @@ module IdeHelper def new_ide_data { 'project-path' => @project&.path_with_namespace, - 'csp-nonce' => content_security_policy_nonce + 'csp-nonce' => content_security_policy_nonce, + # We will replace these placeholders in the FE + 'ide-remote-path' => ide_remote_path(remote_host: ':remote_host', remote_path: ':remote_path') } end @@ -42,9 +47,6 @@ module IdeHelper 'render-whitespace-in-code': current_user.render_whitespace_in_code.to_s, 'codesandbox-bundler-url': Gitlab::CurrentSettings.web_ide_clientside_preview_bundler_url, 'default-branch' => @project && @project.default_branch, - 'file-path' => @path, - 'merge-request' => @merge_request, - 'fork-info' => @fork_info&.to_json, 'project' => convert_to_project_entity_json(@project), 'enable-environments-guidance' => enable_environments_guidance?.to_s, 'preview-markdown-path' => @project && preview_markdown_path(@project), diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb index abfa55cff24..0650af33e37 100644 --- a/app/helpers/integrations_helper.rb +++ b/app/helpers/integrations_helper.rb @@ -185,6 +185,29 @@ module IntegrationsHelper target_type_i18n_map[target_type] || target_type end + def integration_webhook_event_human_name(event) + event_i18n_map = { + repository_update_events: _('Repository update events'), + push_events: _('Push events'), + tag_push_events: s_('Webhooks|Tag push events'), + note_events: _('Comments'), + confidential_note_events: s_('Webhooks|Confidential comments'), + issues_events: s_('Webhooks|Issues events'), + confidential_issues_events: s_('Webhooks|Confidential issues events'), + subgroup_events: s_('Webhooks|Subgroup events'), + member_events: s_('Webhooks|Member events'), + merge_requests_events: s_('Webhooks|Merge request events'), + job_events: s_('Webhooks|Job events'), + pipeline_events: s_('Webhooks|Pipeline events'), + wiki_page_events: s_('Webhooks|Wiki page events'), + deployment_events: s_('Webhooks|Deployment events'), + feature_flag_events: s_('Webhooks|Feature flag events'), + releases_events: s_('Webhooks|Releases events') + } + + event_i18n_map[event] || event.to_s.humanize + end + extend self private diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb index 5d537767eaf..6fad1346426 100644 --- a/app/helpers/invite_members_helper.rb +++ b/app/helpers/invite_members_helper.rb @@ -20,6 +20,7 @@ module InviteMembersHelper end end + # Overridden in EE def common_invite_group_modal_data(source, member_class, is_project) { id: source.id, @@ -29,7 +30,8 @@ module InviteMembersHelper invalid_groups: source.related_group_ids, help_link: help_page_url('user/permissions'), is_project: is_project, - access_levels: member_class.permissible_access_level_roles(current_user, source).to_json + access_levels: member_class.permissible_access_level_roles(current_user, source).to_json, + full_path: source.full_path }.merge(group_select_data(source)) end @@ -39,7 +41,8 @@ module InviteMembersHelper id: source.id, root_id: source.root_ancestor&.id, name: source.name, - default_access_level: Gitlab::Access::GUEST + default_access_level: Gitlab::Access::GUEST, + full_path: source.full_path } if show_invite_members_for_task?(source) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index fd181109a94..2b21d8c51e6 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -138,15 +138,15 @@ module IssuablesHelper def issuable_meta_author_status(author) return "" unless author&.status&.customized? && status = user_status(author) - "#{status}".html_safe + status.to_s.html_safe end def issuable_meta(issuable, project) output = [] if issuable.respond_to?(:work_item_type) && WorkItems::Type::WI_TYPES_WITH_CREATED_HEADER.include?(issuable.work_item_type.base_type) - output << content_tag(:span, sprite_icon("#{issuable.work_item_type.icon_name}", css_class: 'gl-icon gl-vertical-align-middle gl-text-gray-500'), class: 'gl-mr-2', aria: { hidden: 'true' }) - output << content_tag(:span, s_('IssuableStatus|%{wi_type} created %{created_at} by ').html_safe % { wi_type: issuable.issue_type.capitalize, created_at: time_ago_with_tooltip(issuable.created_at) }, class: 'gl-mr-2') + output << content_tag(:span, sprite_icon(issuable.work_item_type.icon_name.to_s, css_class: 'gl-icon gl-vertical-align-middle gl-text-gray-500'), class: 'gl-mr-2', aria: { hidden: 'true' }) + output << content_tag(:span, s_('IssuableStatus|%{wi_type} created %{created_at} by ').html_safe % { wi_type: IntegrationsHelper.integration_issue_type(issuable.issue_type), created_at: time_ago_with_tooltip(issuable.created_at) }, class: 'gl-mr-2') else output << content_tag(:span, s_('IssuableStatus|Created %{created_at} by').html_safe % { created_at: time_ago_with_tooltip(issuable.created_at) }, class: 'gl-mr-2') end @@ -207,7 +207,14 @@ module IssuablesHelper def assigned_issuables_count(issuable_type) case issuable_type when :issues - current_user.assigned_open_issues_count + if Feature.enabled?(:limit_assigned_issues_count) + ::Users::AssignedIssuesCountService.new( + current_user: current_user, + max_limit: User::MAX_LIMIT_FOR_ASSIGNEED_ISSUES_COUNT + ).count + else + current_user.assigned_open_issues_count + end when :merge_requests current_user.assigned_open_merge_requests_count else @@ -215,6 +222,16 @@ module IssuablesHelper end end + def assigned_open_issues_count_text + count = assigned_issuables_count(:issues) + + if Feature.enabled?(:limit_assigned_issues_count) && count > User::MAX_LIMIT_FOR_ASSIGNEED_ISSUES_COUNT - 1 + "#{count - 1}+" + else + count.to_s + end + end + def issuable_reference(issuable) @show_full_reference ? issuable.to_reference(full: true) : issuable.to_reference(@group || @project) end @@ -348,12 +365,10 @@ module IssuablesHelper else [_("Closed"), "merge-request-close"] end + elsif issuable.open? + [_("Open"), "issues"] else - if issuable.open? - [_("Open"), "issues"] - else - [_("Closed"), "issue-closed"] - end + [_("Closed"), "issue-closed"] end end @@ -414,6 +429,7 @@ module IssuablesHelper id: issuable[:id], severity: issuable[:severity], timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours, + canCreateTimelogs: issuable.dig(:current_user, :can_create_timelogs), createNoteEmail: issuable[:create_note_email], issuableType: issuable[:type] } diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 932a50d9451..1d68dccc741 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -256,6 +256,18 @@ module IssuesHelper ) end + def dashboard_issues_list_data(current_user) + { + calendar_path: url_for(safe_params.merge(calendar_url_options)), + empty_state_svg_path: image_path('illustrations/issue-dashboard_results-without-filter.svg'), + initial_sort: current_user&.user_preference&.issues_sort, + is_public_visibility_restricted: + Gitlab::CurrentSettings.restricted_visibility_levels&.include?(Gitlab::VisibilityLevel::PUBLIC).to_s, + is_signed_in: current_user.present?.to_s, + rss_path: url_for(safe_params.merge(rss_url_options)) + } + end + def issues_form_data(project) { new_issue_path: new_project_issue_path(project) diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index 0123eb68c9a..8c069bc828b 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -159,7 +159,7 @@ module LabelsHelper end def label_subscription_toggle_button_text(label, project = nil) - label.subscribed?(current_user, project) ? 'Unsubscribe' : 'Subscribe' + label.subscribed?(current_user, project) ? _('Unsubscribe') : _('Subscribe') end def create_label_title(subject) @@ -219,8 +219,8 @@ module LabelsHelper }.merge(opts) end - def issuable_types - ['issues', 'merge requests'] + def labels_function_introduction + _('Labels can be applied to issues and merge requests. Group labels are available for any project within the group.') end def show_labels_full_path?(project, group) diff --git a/app/helpers/listbox_helper.rb b/app/helpers/listbox_helper.rb index 16caf862c7b..0aaeb39c82d 100644 --- a/app/helpers/listbox_helper.rb +++ b/app/helpers/listbox_helper.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true module ListboxHelper - DROPDOWN_CONTAINER_CLASSES = %w[dropdown b-dropdown gl-new-dropdown btn-group js-redirect-listbox].freeze + DROPDOWN_CONTAINER_CLASSES = %w[dropdown b-dropdown gl-dropdown btn-group js-redirect-listbox].freeze DROPDOWN_BUTTON_CLASSES = %w[btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle].freeze - DROPDOWN_INNER_CLASS = 'gl-new-dropdown-button-text' + DROPDOWN_INNER_CLASS = 'gl-dropdown-button-text' DROPDOWN_ICON_CLASS = 'gl-button-icon dropdown-chevron gl-icon' # Creates a listbox component with redirect behavior. diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index 9baea43b77d..ed9129ff78b 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -59,12 +59,20 @@ module MarkupHelper # as Markdown. HTML tags in the parsed output are not counted toward the # +max_chars+ limit. If the length limit falls within a tag's contents, then # the tag contents are truncated without removing the closing tag. - def first_line_in_markdown(object, attribute, max_chars = nil, options = {}) + def first_line_in_markdown(object, attribute, max_chars = nil, is_todo: false, **options) md = markdown_field(object, attribute, options.merge(post_process: false)) return unless md.present? + includes_code = false + tags = %w(a gl-emoji b strong i em pre code p span) - tags << 'img' if options[:allow_images] + + if is_todo + fragment = Nokogiri::HTML.fragment(md) + includes_code = fragment.css('code').any? + + md = fragment + end context = markdown_field_render_context(object, attribute, options) context.reverse_merge!(truncate_visible_max_chars: max_chars || md.length) @@ -77,12 +85,19 @@ module MarkupHelper %w( style data-src data-name data-unicode-version data-html data-reference-type data-project-path data-iid data-mr-title + data-user ) ) + # Extra span with relative positioning relative due to system font being behind + # background color when username is first word of mention + if is_todo && !includes_code + text = "<span class=\"gl-relative\">\"</span>#{text}<span class=\"gl-relative\">\"</span>" + end + # since <img> tags are stripped, this can leave empty <a> tags hanging around # (as our markdown wraps images in links) - options[:allow_images] ? text : strip_empty_link_tags(text).html_safe + strip_empty_link_tags(text).html_safe end def markdown(text, context = {}) diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index f1f5f941edd..29f94adcc78 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -14,19 +14,17 @@ module MembersHelper else "deny #{member.user.name}'s request to join" end + elsif member.user + "remove #{member.user.name} from" else - if member.user - "remove #{member.user.name} from" - else - e = RuntimeError.new("Data integrity error: no associated user for member ID #{member.id}") - Gitlab::ErrorTracking.track_exception(e, - member_id: member.id, - invite_email: member.invite_email, - invite_accepted_at: member.invite_accepted_at, - source_id: member.source_id, - source_type: member.source_type) - "remove this orphaned member from" - end + e = RuntimeError.new("Data integrity error: no associated user for member ID #{member.id}") + Gitlab::ErrorTracking.track_exception(e, + member_id: member.id, + invite_email: member.invite_email, + invite_accepted_at: member.invite_accepted_at, + source_id: member.source_id, + source_type: member.source_type) + "remove this orphaned member from" end "#{text} #{action} the #{member.source.human_name} #{source_text(member)}?" diff --git a/app/helpers/nav/top_nav_helper.rb b/app/helpers/nav/top_nav_helper.rb index 751900f4593..bd4d661ab49 100644 --- a/app/helpers/nav/top_nav_helper.rb +++ b/app/helpers/nav/top_nav_helper.rb @@ -101,8 +101,7 @@ module Nav builder.add_primary_menu_item_with_shortcut( header: top_nav_localized_headers[:switch_to], active: nav == 'project' || active_nav_link?(path: %w[root#index projects#trending projects#starred dashboard/projects#index]), - css_class: 'qa-projects-dropdown', - data: { track_label: "projects_dropdown", track_action: "click_dropdown" }, + data: { track_label: "projects_dropdown", track_action: "click_dropdown", qa_selector: "projects_dropdown" }, view: PROJECTS_VIEW, shortcut_href: dashboard_projects_path, **projects_menu_item_attrs @@ -116,8 +115,7 @@ module Nav builder.add_primary_menu_item_with_shortcut( header: top_nav_localized_headers[:switch_to], active: nav == 'group' || active_nav_link?(path: %w[dashboard/groups explore/groups]), - css_class: 'qa-groups-dropdown', - data: { track_label: "groups_dropdown", track_action: "click_dropdown" }, + data: { track_label: "groups_dropdown", track_action: "click_dropdown", qa_selector: "groups_dropdown" }, view: GROUPS_VIEW, shortcut_href: dashboard_groups_path, **groups_menu_item_attrs @@ -133,7 +131,7 @@ module Nav href: dashboard_milestones_path, active: active_nav_link?(controller: 'dashboard/milestones'), icon: 'clock', - data: { qa_selector: 'milestones_link', **menu_data_tracking_attrs('milestones') }, + data: { **menu_data_tracking_attrs('milestones') }, shortcut_class: 'dashboard-shortcuts-milestones' ) end @@ -156,7 +154,7 @@ module Nav href: activity_dashboard_path, active: active_nav_link?(path: 'dashboard#activity'), icon: 'history', - data: { qa_selector: 'activity_link', **menu_data_tracking_attrs('activity') }, + data: { **menu_data_tracking_attrs('activity') }, shortcut_class: 'dashboard-shortcuts-activity' ) end @@ -173,9 +171,8 @@ module Nav title: title, active: active_nav_link?(controller: 'admin/dashboard'), icon: 'admin', - css_class: 'qa-admin-area-link', href: admin_root_path, - data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) } + data: { qa_selector: 'admin_area_link', **menu_data_tracking_attrs(title) } ) end diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 0cf2c5cea4c..bf3b132e33a 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -21,11 +21,11 @@ module NavHelper def page_gutter_class moved_sidebar_enabled = current_controller?('merge_requests') && moved_mr_sidebar_enabled? - if page_has_markdown? && !current_controller?('conflicts') + if (page_has_markdown? || current_path?('projects/merge_requests#diffs')) && !current_controller?('conflicts') if cookies[:collapsed_gutter] == 'true' - ["page-gutter", "#{'right-sidebar-collapsed' unless moved_sidebar_enabled}"] + ["page-gutter", ('right-sidebar-collapsed' unless moved_sidebar_enabled).to_s] else - ["page-gutter", "#{'right-sidebar-expanded' unless moved_sidebar_enabled}"] + ["page-gutter", ('right-sidebar-expanded' unless moved_sidebar_enabled).to_s] end elsif current_path?('jobs#show') %w[page-gutter build-sidebar right-sidebar-expanded] diff --git a/app/helpers/numbers_helper.rb b/app/helpers/numbers_helper.rb index 38d3f90dd55..7184a9c075c 100644 --- a/app/helpers/numbers_helper.rb +++ b/app/helpers/numbers_helper.rb @@ -6,7 +6,7 @@ module NumbersHelper count = resource.page.total_count_with_limit(:all, limit: limit) if count > limit - number_with_delimiter(count - 1, options) + '+' + "#{number_with_delimiter(count - 1, options)}+" else number_with_delimiter(count, options) end diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb index c0665463706..6f7b2877100 100644 --- a/app/helpers/page_layout_helper.rb +++ b/app/helpers/page_layout_helper.rb @@ -80,8 +80,8 @@ module PageLayoutHelper tags = [] page_card_attributes.each_with_index do |pair, i| - tags << tag(:meta, property: "twitter:label#{i + 1}", content: pair[0]) - tags << tag(:meta, property: "twitter:data#{i + 1}", content: pair[1]) + tags << tag.meta(property: "twitter:label#{i + 1}", content: pair[0]) + tags << tag.meta(property: "twitter:data#{i + 1}", content: pair[1]) end tags.join.html_safe diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index 57afe0ed0be..f2b7c0064e4 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -100,11 +100,19 @@ module PreferencesHelper def language_choices options_for_select( - selectable_locales_with_translation_level.sort, + selectable_locales_with_translation_level(Gitlab::I18n::MINIMUM_TRANSLATION_LEVEL).sort, current_user.preferred_language ) end + def default_preferred_language_choices + options_for_select( + selectable_locales_with_translation_level( + PreferredLanguageSwitcherHelper::SWITCHER_MINIMUM_TRANSLATION_LEVEL).sort, + Gitlab::CurrentSettings.default_preferred_language + ) + end + def integration_views [].tap do |views| views << { name: 'gitpod', message: gitpod_enable_description, message_url: gitpod_url_placeholder, help_link: help_page_path('integration/gitpod.md') } if Gitlab::CurrentSettings.gitpod_enabled @@ -136,8 +144,8 @@ module PreferencesHelper first_day_of_week_choices.rassoc(Gitlab::CurrentSettings.first_day_of_week).first end - def selectable_locales_with_translation_level - Gitlab::I18n.selectable_locales.map do |code, language| + def selectable_locales_with_translation_level(minimum_level) + Gitlab::I18n.selectable_locales(minimum_level).map do |code, language| [ s_("i18n|%{language} (%{percent_translated}%% translated)") % { language: language, diff --git a/app/helpers/preferred_language_switcher_helper.rb b/app/helpers/preferred_language_switcher_helper.rb new file mode 100644 index 00000000000..1cad4f842ec --- /dev/null +++ b/app/helpers/preferred_language_switcher_helper.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module PreferredLanguageSwitcherHelper + SWITCHER_MINIMUM_TRANSLATION_LEVEL = 90 + + def ordered_selectable_locales + highly_translated_languages = Gitlab::I18n.selectable_locales(SWITCHER_MINIMUM_TRANSLATION_LEVEL) + # see https://docs.gitlab.com/ee/development/i18n/externalization.html#adding-a-new-language + # for translation standards + locale_list = highly_translated_languages.filter_map do |code, language| + percentage = Gitlab::I18n.percentage_translated_for(code) + { + value: code, + percentage: percentage, + text: language.split('-').last.strip + } + end + + locale_list.sort_by { |item| item[:percentage] }.reverse + end +end diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb index bfe39bbc211..979b979fba7 100644 --- a/app/helpers/profiles_helper.rb +++ b/app/helpers/profiles_helper.rb @@ -46,6 +46,14 @@ module ProfilesHelper end end + def ssh_key_usage_types + { + s_('SSHKey|Authentication & Signing') => 'auth_and_signing', + s_('SSHKey|Authentication') => 'auth', + s_('SSHKey|Signing') => 'signing' + } + end + # Overridden in EE::ProfilesHelper#ssh_key_expiration_tooltip def ssh_key_expiration_tooltip(key) return key.errors.full_messages.join(', ') if key.errors.full_messages.any? diff --git a/app/helpers/programming_languages_helper.rb b/app/helpers/programming_languages_helper.rb new file mode 100644 index 00000000000..c50872aec6f --- /dev/null +++ b/app/helpers/programming_languages_helper.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module ProgrammingLanguagesHelper + def search_language_placeholder + placeholder = _('Language') + + return placeholder unless params[:language].present? + + programming_languages.find { |language| language.id.to_s == params[:language] }&.name || + placeholder + end + + def programming_languages + @programming_languages ||= ProgrammingLanguage.most_popular + end + + def language_state_class(language) + params[:language] == language.id.to_s ? 'is-active' : '' + end +end diff --git a/app/helpers/projects/ml/experiments_helper.rb b/app/helpers/projects/ml/experiments_helper.rb index 29bd879859e..a67484e3d2f 100644 --- a/app/helpers/projects/ml/experiments_helper.rb +++ b/app/helpers/projects/ml/experiments_helper.rb @@ -9,7 +9,9 @@ module Projects items = candidates.map do |candidate| { **candidate.params.to_h { |p| [p.name, p.value] }, - **candidate.latest_metrics.to_h { |m| [m.name, number_with_precision(m.value, precision: 4)] } + **candidate.latest_metrics.to_h { |m| [m.name, number_with_precision(m.value, precision: 4)] }, + artifact: link_to_artifact(candidate), + details: link_to_details(candidate) } end @@ -19,6 +21,42 @@ module Projects def unique_logged_names(candidates, &selector) Gitlab::Json.generate(candidates.flat_map(&selector).map(&:name).uniq) end + + def candidate_as_data(candidate) + data = { + params: candidate.params, + metrics: candidate.latest_metrics, + info: { + iid: candidate.iid, + path_to_artifact: link_to_artifact(candidate), + experiment_name: candidate.experiment.name, + path_to_experiment: link_to_experiment(candidate), + status: candidate.status + } + } + + Gitlab::Json.generate(data) + end + + private + + def link_to_artifact(candidate) + artifact = candidate.artifact + + return unless artifact.present? + + project_package_path(candidate.experiment.project, artifact) + end + + def link_to_details(candidate) + project_ml_candidate_path(candidate.experiment.project, candidate.iid) + end + + def link_to_experiment(candidate) + experiment = candidate.experiment + + project_ml_experiment_path(experiment.project, experiment.iid) + end end end end diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb index edbdb9d4adf..5c62920cd89 100644 --- a/app/helpers/projects/pipeline_helper.rb +++ b/app/helpers/projects/pipeline_helper.rb @@ -12,6 +12,7 @@ module Projects graphql_resource_etag: graphql_etag_pipeline_path(pipeline), metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: project.namespace, project_id: project, format: :json), pipeline_iid: pipeline.iid, + pipeline_path: pipeline_path(pipeline), pipeline_project_path: project.full_path, total_job_count: pipeline.total_size, summary_endpoint: summary_project_pipeline_tests_path(project, pipeline, format: :json), diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index e41a3fa5091..682febe9dc9 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -142,6 +142,8 @@ module ProjectsHelper end def project_search_tabs?(tab) + return false unless @project.present? + abilities = Array(search_tab_ability_map[tab]) abilities.any? { |ability| can?(current_user, ability, @project) } @@ -254,11 +256,8 @@ module ProjectsHelper end end - # TODO: Remove this method when removing the feature flag - # https://gitlab.com/gitlab-org/gitlab/merge_requests/11209#note_162234863 - # make sure to remove from the EE specific controller as well: ee/app/controllers/ee/dashboard/projects_controller.rb def show_projects?(projects, params) - Feature.enabled?(:project_list_filter_bar) || !!(params[:personal] || params[:name] || any_projects?(projects)) + !!(params[:personal] || params[:name] || params[:language] || any_projects?(projects)) end def push_to_create_project_command(user = current_user) @@ -465,9 +464,9 @@ module ProjectsHelper def project_coverage_chart_data_attributes(daily_coverage_options, ref) { graph_endpoint: "#{daily_coverage_options[:graph_api_path]}?#{daily_coverage_options[:base_params].to_query}", - graph_start_date: "#{daily_coverage_options[:base_params][:start_date].strftime('%b %d')}", - graph_end_date: "#{daily_coverage_options[:base_params][:end_date].strftime('%b %d')}", - graph_ref: "#{ref}", + graph_start_date: daily_coverage_options[:base_params][:start_date].strftime('%b %d'), + graph_end_date: daily_coverage_options[:base_params][:end_date].strftime('%b %d'), + graph_ref: ref.to_s, graph_csv_path: "#{daily_coverage_options[:download_path]}?#{daily_coverage_options[:base_params].to_query}" } end @@ -480,6 +479,32 @@ module ProjectsHelper format_cached_count(1000, number) end + def fork_divergence_message(counts) + messages = [] + + if counts[:behind].nil? || counts[:ahead].nil? + return s_('ForksDivergence|Fork has diverged from upstream repository') + end + + if counts[:behind] > 0 + messages << s_("ForksDivergence|%{behind} %{commit_word} behind") % { + behind: counts[:behind], commit_word: n_('commit', 'commits', counts[:behind]) + } + end + + if counts[:ahead] > 0 + messages << s_("ForksDivergence|%{ahead} %{commit_word} ahead of") % { + ahead: counts[:ahead], commit_word: n_('commit', 'commits', counts[:ahead]) + } + end + + if messages.blank? + s_('ForksDivergence|Up to date with upstream repository') + else + s_("ForksDivergence|%{messages} upstream repository") % { messages: messages.join(', ') } + end + end + private def localized_access_names @@ -531,10 +556,10 @@ module ProjectsHelper def search_tab_ability_map @search_tab_ability_map ||= tab_ability_map.merge( - blobs: :download_code, - commits: :download_code, + blobs: :read_code, + commits: :read_code, merge_requests: :read_merge_request, - notes: [:read_merge_request, :download_code, :read_issue, :read_snippet], + notes: [:read_merge_request, :read_code, :read_issue, :read_snippet], members: :read_project_member ) end @@ -658,7 +683,6 @@ module ProjectsHelper lfsEnabled: !!project.lfs_enabled, emailsDisabled: project.emails_disabled?, metricsDashboardAccessLevel: feature.metrics_dashboard_access_level, - operationsAccessLevel: feature.operations_access_level, monitorAccessLevel: feature.monitor_access_level, showDefaultAwardEmojis: project.show_default_award_emojis?, warnAboutPotentiallyUnwantedCharacters: project.warn_about_potentially_unwanted_characters?, @@ -680,7 +704,7 @@ module ProjectsHelper def find_file_path return unless @project && !@project.empty_repo? - return unless can?(current_user, :download_code, @project) + return unless can?(current_user, :read_code, @project) ref = @ref || @project.repository.root_ref diff --git a/app/helpers/routing/pseudonymization_helper.rb b/app/helpers/routing/pseudonymization_helper.rb index dce0517690d..63e2b377fef 100644 --- a/app/helpers/routing/pseudonymization_helper.rb +++ b/app/helpers/routing/pseudonymization_helper.rb @@ -12,6 +12,7 @@ module Routing tab glm_source glm_content + _gl ].freeze def initialize(request_object, group, project) diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index b8ac2afa7d6..e03365ad5f1 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -40,6 +40,7 @@ module SearchHelper [ groups_autocomplete(term), projects_autocomplete(term), + users_autocomplete(term), issue_autocomplete(term) ].flatten end @@ -308,8 +309,8 @@ module SearchHelper { category: "Groups", id: group.id, - value: "#{search_result_sanitize(group.name)}", - label: "#{search_result_sanitize(group.full_name)}", + value: search_result_sanitize(group.name), + label: search_result_sanitize(group.full_name), url: group_path(group), avatar_url: group.avatar_url || '' } @@ -343,14 +344,32 @@ module SearchHelper { category: "Projects", id: p.id, - value: "#{search_result_sanitize(p.name)}", - label: "#{search_result_sanitize(p.full_name)}", + value: search_result_sanitize(p.name), + label: search_result_sanitize(p.full_name), url: project_path(p), avatar_url: p.avatar_url || '' } end end + def users_autocomplete(term, limit = 5) + return [] unless current_user && Ability.allowed?(current_user, :read_users_list) + + SearchService + .new(current_user, { scope: 'users', per_page: limit, search: term }) + .search_objects + .map do |user| + { + category: "Users", + id: user.id, + value: search_result_sanitize(user.name), + label: search_result_sanitize(user.username), + url: user_path(user), + avatar_url: user.avatar_url || '' + } + end + end + def recent_merge_requests_autocomplete(term) return [] unless current_user @@ -427,20 +446,38 @@ module SearchHelper result end + def code_tab_condition + return true if project_search_tabs?(:blobs) + + @project.nil? && search_service.show_elasticsearch_tabs? && feature_flag_tab_enabled?(:global_search_code_tab) + end + + def wiki_tab_condition + return true if project_search_tabs?(:wiki) + + @project.nil? && search_service.show_elasticsearch_tabs? && feature_flag_tab_enabled?(:global_search_wiki_tab) + end + + def commits_tab_condition + return true if project_search_tabs?(:commits) + + @project.nil? && search_service.show_elasticsearch_tabs? && feature_flag_tab_enabled?(:global_search_commits_tab) + end + # search page scope navigation def search_navigation { projects: { sort: 1, label: _("Projects"), data: { qa_selector: 'projects_tab' }, condition: @project.nil? }, - blobs: { sort: 2, label: _("Code"), data: { qa_selector: 'code_tab' }, condition: project_search_tabs?(:blobs) || (search_service.show_elasticsearch_tabs? && feature_flag_tab_enabled?(:global_search_code_tab)) }, + blobs: { sort: 2, label: _("Code"), data: { qa_selector: 'code_tab' }, condition: code_tab_condition }, # sort: 3 is reserved for EE items issues: { sort: 4, label: _("Issues"), condition: project_search_tabs?(:issues) || feature_flag_tab_enabled?(:global_search_issues_tab) }, merge_requests: { sort: 5, label: _("Merge requests"), condition: project_search_tabs?(:merge_requests) || feature_flag_tab_enabled?(:global_search_merge_requests_tab) }, - wiki_blobs: { sort: 6, label: _("Wiki"), condition: project_search_tabs?(:wiki) || search_service.show_elasticsearch_tabs? }, - commits: { sort: 7, label: _("Commits"), condition: project_search_tabs?(:commits) || (search_service.show_elasticsearch_tabs? && feature_flag_tab_enabled?(:global_search_commits_tab)) }, + wiki_blobs: { sort: 6, label: _("Wiki"), condition: wiki_tab_condition }, + commits: { sort: 7, label: _("Commits"), condition: commits_tab_condition }, notes: { sort: 8, label: _("Comments"), condition: project_search_tabs?(:notes) || search_service.show_elasticsearch_tabs? }, milestones: { sort: 9, label: _("Milestones"), condition: project_search_tabs?(:milestones) || @project.nil? }, - users: { sort: 10, label: _("Users"), condition: show_user_search_tab? }, - snippet_titles: { sort: 11, label: _("Titles and Descriptions"), search: { snippets: true, group_id: nil, project_id: nil }, condition: @show_snippets.present? && @project.nil? } + users: { sort: 10, label: _("Users"), condition: show_user_search_tab? }, + snippet_titles: { sort: 11, label: _("Titles and Descriptions"), search: { snippets: true, group_id: nil, project_id: nil }, condition: search_service.show_snippets? && @project.nil? } } end @@ -545,12 +582,10 @@ module SearchHelper else :success end + elsif issuable.closed? + :info else - if issuable.closed? - :info - else - :success - end + :success end end @@ -566,7 +601,7 @@ module SearchHelper end def feature_flag_tab_enabled?(flag) - @group || Feature.enabled?(flag, current_user, type: :ops) + @group.present? || Feature.enabled?(flag, current_user, type: :ops) end def sanitized_search_params diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb index 9002fdda128..cbee02a28c0 100644 --- a/app/helpers/sidebars_helper.rb +++ b/app/helpers/sidebars_helper.rb @@ -20,9 +20,8 @@ module SidebarsHelper end end - def project_sidebar_context(project, user, current_ref) - context_data = project_sidebar_context_data(project, user, current_ref) - + def project_sidebar_context(project, user, current_ref, ref_type: nil) + context_data = project_sidebar_context_data(project, user, current_ref, ref_type: ref_type) Sidebars::Projects::Context.new(**context_data) end @@ -83,12 +82,13 @@ module SidebarsHelper tracking_attrs('user_side_navigation', 'render', 'user_side_navigation') end - def project_sidebar_context_data(project, user, current_ref) + def project_sidebar_context_data(project, user, current_ref, ref_type: nil) { current_user: user, container: project, learn_gitlab_enabled: learn_gitlab_enabled?(project), current_ref: current_ref, + ref_type: ref_type, jira_issues_integration: project_jira_issues_integration?, can_view_pipeline_editor: can_view_pipeline_editor?(project), show_cluster_hint: show_gke_cluster_integration_callout?(project) diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index a711f36fe05..4a9596a1347 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -44,25 +44,18 @@ module SortingHelper # rubocop: enable Metrics/AbcSize def projects_sort_options_hash - use_old_sorting = Feature.disabled?(:project_list_filter_bar) || current_controller?('admin/projects') - options = { 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 + sort_value_stars_desc => sort_title_stars, + sort_value_oldest_activity => sort_title_oldest_activity, + sort_value_oldest_created => sort_title_oldest_created, + sort_value_recently_created => sort_title_recently_created, + sort_value_stars_desc => sort_title_most_stars } - if use_old_sorting - options = options.merge({ - sort_value_oldest_activity => sort_title_oldest_activity, - sort_value_oldest_created => sort_title_oldest_created, - sort_value_recently_created => sort_title_recently_created, - sort_value_stars_desc => sort_title_most_stars - }) - end - if current_controller?('admin/projects') options[sort_value_largest_repo] = sort_title_largest_repo end @@ -79,29 +72,6 @@ module SortingHelper } end - def projects_sort_option_titles - # Only used for the project filter search bar - projects_sort_options_hash.merge({ - sort_value_oldest_activity => sort_title_latest_activity, - sort_value_oldest_created => sort_title_created_date, - sort_value_name_desc => sort_title_name, - sort_value_stars_asc => sort_title_stars - }) - end - - def projects_reverse_sort_options_hash - { - sort_value_latest_activity => sort_value_oldest_activity, - sort_value_recently_created => sort_value_oldest_created, - sort_value_name => sort_value_name_desc, - sort_value_stars_desc => sort_value_stars_asc, - sort_value_oldest_activity => sort_value_latest_activity, - sort_value_oldest_created => sort_value_recently_created, - sort_value_name_desc => sort_value_name, - sort_value_stars_asc => sort_value_stars_desc - } - end - def forks_reverse_sort_options_hash { sort_value_recently_created => sort_value_oldest_created, @@ -188,13 +158,6 @@ module SortingHelper } end - def runners_sort_options_hash - { - sort_value_created_date => sort_title_created_date, - sort_value_contacted_date => sort_title_contacted_date - } - end - def starrers_sort_options_hash { sort_value_name => sort_title_name, @@ -308,7 +271,7 @@ module SortingHelper end def sort_direction_button(reverse_url, reverse_sort, sort_value) - link_class = 'gl-button btn btn-default btn-icon has-tooltip reverse-sort-btn qa-reverse-sort rspec-reverse-sort' + link_class = 'gl-button btn btn-default btn-icon has-tooltip reverse-sort-btn rspec-reverse-sort' icon = sort_direction_icon(sort_value) url = reverse_url @@ -329,13 +292,6 @@ module SortingHelper sort_direction_button(url, reverse_sort, sort_value) end - def project_sort_direction_button(sort_value) - reverse_sort = projects_reverse_sort_options_hash[sort_value] - url = filter_projects_path(sort: reverse_sort) - - sort_direction_button(url, reverse_sort, sort_value) - end - def packages_sort_options_hash { sort_value_recently_created => sort_title_created_date, diff --git a/app/helpers/ssh_keys_helper.rb b/app/helpers/ssh_keys_helper.rb index 17a9fd6146d..4cd40836335 100644 --- a/app/helpers/ssh_keys_helper.rb +++ b/app/helpers/ssh_keys_helper.rb @@ -2,10 +2,14 @@ module SshKeysHelper def ssh_key_delete_modal_data(key, path) + title = _('Delete Key') + { path: path, method: 'delete', qa_selector: 'delete_ssh_key_button', + title: title, + aria_label: title, modal_attributes: { 'data-qa-selector': 'ssh_key_delete_modal', title: _('Are you sure you want to delete this SSH key?'), diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb index 2942765a108..e3e2f423da3 100644 --- a/app/helpers/submodule_helper.rb +++ b/app/helpers/submodule_helper.rb @@ -75,7 +75,7 @@ module SubmoduleHelper return true if url_no_dotgit == [Gitlab.config.gitlab.url, '/', namespace, '/', project].join('') - url_with_dotgit = url_no_dotgit + '.git' + url_with_dotgit = "#{url_no_dotgit}.git" url_with_dotgit == Gitlab::RepositoryUrlBuilder.build([namespace, '/', project].join('')) end @@ -108,7 +108,7 @@ module SubmoduleHelper def relative_self_links(relative_path, commit, old_commit, project) relative_path = relative_path.rstrip - absolute_project_path = "/" + project.full_path + absolute_project_path = "/#{project.full_path}" # Resolve `relative_path` to target path # Assuming `absolute_project_path` is `/g1/p1`: diff --git a/app/helpers/timeboxes_helper.rb b/app/helpers/timeboxes_helper.rb index e0e6229bc6d..307f03e0d64 100644 --- a/app/helpers/timeboxes_helper.rb +++ b/app/helpers/timeboxes_helper.rb @@ -36,7 +36,7 @@ module TimeboxesHelper end end - def milestones_browse_issuables_path(milestone, state: nil, type:) + def milestones_browse_issuables_path(milestone, type:, state: nil) opts = { milestone_title: milestone.title, state: state } if @project diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index be63d28600f..d7c4540544b 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -16,17 +16,20 @@ module TodosHelper def todo_action_name(todo) case todo.action when Todo::ASSIGNED then todo.self_added? ? _('assigned') : _('assigned you') - when Todo::REVIEW_REQUESTED then s_('Todos|requested a review of') + when Todo::REVIEW_REQUESTED then s_('Todos|requested a review') when Todo::MENTIONED, Todo::DIRECTLY_ADDRESSED then format( - s_("Todos|mentioned %{who} on"), who: todo_action_subject(todo) + s_("Todos|mentioned %{who}"), who: todo_action_subject(todo) ) - when Todo::BUILD_FAILED then s_('Todos|The pipeline failed in') - when Todo::MARKED then s_('Todos|added a todo for') + when Todo::BUILD_FAILED then s_('Todos|The pipeline failed') + when Todo::MARKED then s_('Todos|added a to-do item') when Todo::APPROVAL_REQUIRED then format( - s_("Todos|set %{who} as an approver for"), who: todo_action_subject(todo) + s_("Todos|set %{who} as an approver"), who: todo_action_subject(todo) ) when Todo::UNMERGEABLE then s_('Todos|Could not merge') - when Todo::MERGE_TRAIN_REMOVED then s_("Todos|Removed from Merge Train:") + when Todo::MERGE_TRAIN_REMOVED then s_("Todos|Removed from Merge Train") + when Todo::MEMBER_ACCESS_REQUESTED then format( + s_("Todos|has requested access to group %{which}"), which: _(todo.target.name) + ) end end @@ -37,45 +40,48 @@ module TodosHelper end end - def todo_target_link(todo) - text = raw(todo_target_type_name(todo) + ' ') + - if todo.for_commit? - content_tag(:span, todo.target_reference, class: 'commit-sha') - else - todo.target_reference - end + def todo_target_name(todo) + return todo.target_reference unless todo.for_commit? - link_to text, todo_target_path(todo) + content_tag(:span, todo.target_reference, class: 'commit-sha') end def todo_target_title(todo) - # Design To Dos' filenames are displayed in `#todo_target_link` (see `Design#to_reference`), + # Design To Dos' filenames are displayed in `#todo_target_name` (see `Design#to_reference`), # so to avoid displaying duplicate filenames in the To Do list for designs, # we return an empty string here. - return "" if todo.target.blank? || todo.for_design? + return "" if todo.target.blank? || todo.for_design? || todo.member_access_requested? - "\"#{todo.target.title}\"" + todo.target.title.to_s end def todo_parent_path(todo) if todo.resource_parent.is_a?(Group) - link_to todo.resource_parent.name, group_path(todo.resource_parent) + todo.resource_parent.name else - link_to_project(todo.project) + title = content_tag(:span, todo.project.name, class: 'project-name') + namespace = content_tag(:span, "#{todo.project.namespace.human_name} / ", class: 'namespace-name') + + title.prepend(namespace) if todo.project.namespace + + title end end - def todo_target_type_name(todo) - return _('design') if todo.for_design? - return _('alert') if todo.for_alert? - - target_type = if todo.for_issue_or_work_item? + def todo_target_aria_label(todo) + target_type = if todo.for_design? + _('Design') + elsif todo.for_alert? + _('Alert') + elsif todo.member_access_requested? + _('Group') + elsif todo.for_issue_or_work_item? IntegrationsHelper.integration_issue_type(todo.target.issue_type) else IntegrationsHelper.integration_todo_target_type(todo.target_type) end - target_type.titleize.downcase + "#{target_type} #{todo_target_name(todo)}" end def todo_target_path(todo) @@ -92,6 +98,8 @@ module TodosHelper elsif todo.for_issue_or_work_item? path_options[:only_path] = true Gitlab::UrlBuilder.build(todo.target, **path_options) + elsif todo.member_access_requested? + todo.access_request_url else path = [todo.resource_parent, todo.target] @@ -123,18 +131,18 @@ module TodosHelper when MergeRequest case state when 'closed' - background_class = 'gl-bg-red-500' + variant = 'danger' when 'merged' - background_class = 'gl-bg-blue-500' + variant = 'info' end when Issue - background_class = 'gl-bg-blue-500' if state == 'closed' + variant = 'info' if state == 'closed' when AlertManagement::Alert - background_class = 'gl-bg-blue-500' if state == 'resolved' + variant = 'info' if state == 'resolved' end - tag.span class: "gl-my-0 gl-px-2 status-box #{background_class}" do - raw_state_to_i18n[state] || state.capitalize + content_tag(:span, class: 'todo-target-state') do + gl_badge_tag(raw_state_to_i18n[state] || state.capitalize, { variant: variant, size: 'sm' }) end end @@ -183,7 +191,8 @@ module TodosHelper { id: Todo::REVIEW_REQUESTED, text: s_('Todos|Review requested') }, { id: Todo::MENTIONED, text: s_('Todos|Mentioned') }, { id: Todo::MARKED, text: s_('Todos|Added') }, - { id: Todo::BUILD_FAILED, text: s_('Todos|Pipelines') } + { id: Todo::BUILD_FAILED, text: s_('Todos|Pipelines') }, + { id: Todo::MEMBER_ACCESS_REQUESTED, text: s_('Todos|Member access requested') } ] end @@ -222,10 +231,15 @@ module TodosHelper end content = content_tag(:span, class: css_class) do - "Due #{is_due_today ? "today" : todo.target.due_date.to_s(:medium)}" + format(s_("Todos|Due %{due_date}"), due_date: if is_due_today + _("today") + else + l(todo.target.due_date, + format: Date::DATE_FORMATS[:medium]) + end) end - "· #{content}".html_safe + "#{content} ·".html_safe end def todo_author_display?(todo) diff --git a/app/helpers/tooling/visual_review_helper.rb b/app/helpers/tooling/visual_review_helper.rb index da6eb3ec434..cd3b8be5aac 100644 --- a/app/helpers/tooling/visual_review_helper.rb +++ b/app/helpers/tooling/visual_review_helper.rb @@ -14,10 +14,10 @@ module Tooling GITLAB_ORG_GITLAB_PROJECT_PATH = 'gitlab-org/gitlab' def visual_review_toolbar_options - { 'data-merge-request-id': "#{ENV['REVIEW_APPS_MERGE_REQUEST_IID']}", - 'data-mr-url': "#{GITLAB_INSTANCE_URL}", - 'data-project-id': "#{GITLAB_ORG_GITLAB_PROJECT_ID}", - 'data-project-path': "#{GITLAB_ORG_GITLAB_PROJECT_PATH}", + { 'data-merge-request-id': ENV['REVIEW_APPS_MERGE_REQUEST_IID'].to_s, + 'data-mr-url': GITLAB_INSTANCE_URL, + 'data-project-id': GITLAB_ORG_GITLAB_PROJECT_ID, + 'data-project-path': GITLAB_ORG_GITLAB_PROJECT_PATH, 'data-require-auth': false, 'id': 'review-app-toolbar-script', 'src': 'https://gitlab.com/assets/webpack/visual_review_toolbar.js' } diff --git a/app/helpers/version_check_helper.rb b/app/helpers/version_check_helper.rb index 48548ae9e6a..0bb92dfd118 100644 --- a/app/helpers/version_check_helper.rb +++ b/app/helpers/version_check_helper.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true module VersionCheckHelper + include Gitlab::Utils::StrongMemoize + + SECURITY_ALERT_SEVERITY = 'danger' + def show_version_check? return false unless Gitlab::CurrentSettings.version_check_enabled return false if User.single_user&.requires_usage_stats_consent? @@ -8,6 +12,17 @@ module VersionCheckHelper current_user&.can_read_all_resources? end + def gitlab_version_check + VersionCheck.new.response + end + strong_memoize_attr :gitlab_version_check + + def show_security_patch_upgrade_alert? + return false unless show_version_check? && gitlab_version_check + + gitlab_version_check['severity'] === SECURITY_ALERT_SEVERITY + end + def link_to_version if Gitlab.pre_release? commit_link = link_to(Gitlab.revision, source_host_url + namespace_project_commits_path(source_code_group, source_code_project, Gitlab.revision)) diff --git a/app/helpers/web_hooks/web_hooks_helper.rb b/app/helpers/web_hooks/web_hooks_helper.rb index e95b90c69ef..bda9bf58fb7 100644 --- a/app/helpers/web_hooks/web_hooks_helper.rb +++ b/app/helpers/web_hooks/web_hooks_helper.rb @@ -7,8 +7,6 @@ module WebHooks def show_project_hook_failed_callout?(project:) return false if project_hook_page? return false unless current_user - return false unless Feature.enabled?(:webhooks_failed_callout, project) - return false unless Feature.enabled?(:web_hooks_disable_failed, project) return false unless Ability.allowed?(current_user, :read_web_hooks, project) # Assumes include of Users::CalloutsHelper diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb index 017a1861905..b2b8ca2a120 100644 --- a/app/helpers/wiki_helper.rb +++ b/app/helpers/wiki_helper.rb @@ -60,7 +60,7 @@ module WikiHelper end def wiki_sort_controls(wiki, direction) - link_class = 'gl-button btn btn-default btn-icon has-tooltip reverse-sort-btn qa-reverse-sort rspec-reverse-sort' + link_class = 'gl-button btn btn-default btn-icon has-tooltip reverse-sort-btn rspec-reverse-sort' reversed_direction = direction == 'desc' ? 'asc' : 'desc' icon_class = direction == 'desc' ? 'highest' : 'lowest' title = direction == 'desc' ? _('Sort direction: Descending') : _('Sort direction: Ascending') diff --git a/app/helpers/x509_helper.rb b/app/helpers/x509_helper.rb index 1a9dbefceef..599be0b91f7 100644 --- a/app/helpers/x509_helper.rb +++ b/app/helpers/x509_helper.rb @@ -16,8 +16,4 @@ module X509Helper rescue StandardError {} end - - def x509_signature?(sig) - sig.is_a?(CommitSignatures::X509CommitSignature) || sig.is_a?(Gitlab::X509::Signature) - end end diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index 65ea90d0b5d..ede6007e0e2 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -94,12 +94,13 @@ module Emails end end - def access_token_revoked_email(user, token_name) + def access_token_revoked_email(user, token_name, source = nil) return unless user&.active? @user = user @token_name = token_name @target_url = profile_personal_access_tokens_url + @source = source Gitlab::I18n.with_locale(@user.preferred_language) do mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("A personal access token has been revoked"))) diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index 7cfebf0473f..f1f22d94061 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -14,7 +14,7 @@ class AbuseReport < ApplicationRecord validates :message, presence: true validates :user_id, uniqueness: { message: 'has already been reported' } - scope :by_user, -> (user) { where(user_id: user) } + scope :by_user, ->(user) { where(user_id: user) } scope :with_users, -> { includes(:reporter, :user) } # For CacheMarkdownField diff --git a/app/models/achievements/achievement.rb b/app/models/achievements/achievement.rb new file mode 100644 index 00000000000..904961491b5 --- /dev/null +++ b/app/models/achievements/achievement.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Achievements + class Achievement < ApplicationRecord + include Avatarable + include StripAttribute + + belongs_to :namespace, inverse_of: :achievements, optional: false + + strip_attributes! :name, :description + + validates :name, + presence: true, + length: { maximum: 255 }, + uniqueness: { case_sensitive: false, scope: [:namespace_id] } + validates :description, length: { maximum: 1024 } + end +end diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb index 9f05c87018d..a5a539eae75 100644 --- a/app/models/alert_management/alert.rb +++ b/app/models/alert_management/alert.rb @@ -53,7 +53,7 @@ module AlertManagement validates :fingerprint, allow_blank: true, uniqueness: { scope: :project, conditions: -> { not_resolved }, - message: -> (object, data) { _('Cannot have multiple unresolved alerts') } + message: ->(object, data) { _('Cannot have multiple unresolved alerts') } }, unless: :resolved? validate :hosts_format @@ -74,23 +74,23 @@ module AlertManagement delegate :iid, to: :issue, prefix: true, allow_nil: true delegate :details_url, to: :present - scope :for_iid, -> (iid) { where(iid: iid) } - scope :for_fingerprint, -> (project, fingerprint) { where(project: project, fingerprint: fingerprint) } - scope :for_environment, -> (environment) { where(environment: environment) } - scope :for_assignee_username, -> (assignee_username) { joins(:assignees).merge(User.by_username(assignee_username)) } - scope :search, -> (query) { fuzzy_search(query, [:title, :description, :monitoring_tool, :service]) } + scope :for_iid, ->(iid) { where(iid: iid) } + scope :for_fingerprint, ->(project, fingerprint) { where(project: project, fingerprint: fingerprint) } + scope :for_environment, ->(environment) { where(environment: environment) } + scope :for_assignee_username, ->(assignee_username) { joins(:assignees).merge(User.by_username(assignee_username)) } + scope :search, ->(query) { fuzzy_search(query, [:title, :description, :monitoring_tool, :service]) } scope :not_resolved, -> { without_status(:resolved) } scope :with_prometheus_alert, -> { includes(:prometheus_alert) } 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) } - scope :order_event_count, -> (sort_order) { order(events: sort_order) } + scope :order_start_time, ->(sort_order) { order(started_at: sort_order) } + scope :order_end_time, ->(sort_order) { order(ended_at: sort_order) } + scope :order_event_count, ->(sort_order) { order(events: sort_order) } # Ascending sort order sorts severity from less critical to more critical. # Descending sort order sorts severity from more critical to less critical. # https://gitlab.com/gitlab-org/gitlab/-/issues/221242#what-is-the-expected-correct-behavior - scope :order_severity, -> (sort_order) { order(severity: sort_order == :asc ? :desc : :asc) } + scope :order_severity, ->(sort_order) { order(severity: sort_order == :asc ? :desc : :asc) } scope :order_severity_with_open_prometheus_alert, -> { open.with_prometheus_alert.order(severity: :asc, started_at: :desc) } scope :counts_by_project_id, -> { group(:project_id).count } diff --git a/app/models/alert_management/http_integration.rb b/app/models/alert_management/http_integration.rb index b2686924363..906855d6dfc 100644 --- a/app/models/alert_management/http_integration.rb +++ b/app/models/alert_management/http_integration.rb @@ -28,7 +28,7 @@ module AlertManagement before_validation :ensure_token before_validation :ensure_payload_example_not_nil - scope :for_endpoint_identifier, -> (endpoint_identifier) { where(endpoint_identifier: endpoint_identifier) } + scope :for_endpoint_identifier, ->(endpoint_identifier) { where(endpoint_identifier: endpoint_identifier) } scope :active, -> { where(active: true) } scope :ordered_by_id, -> { order(:id) } diff --git a/app/models/analytics/cycle_analytics/aggregation.rb b/app/models/analytics/cycle_analytics/aggregation.rb index 2e58d64ae95..a888422a6b4 100644 --- a/app/models/analytics/cycle_analytics/aggregation.rb +++ b/app/models/analytics/cycle_analytics/aggregation.rb @@ -1,24 +1,15 @@ # frozen_string_literal: true class Analytics::CycleAnalytics::Aggregation < ApplicationRecord - include IgnorableColumns include FromUnion belongs_to :group, optional: false validates :incremental_runtimes_in_seconds, :incremental_processed_records, :full_runtimes_in_seconds, :full_processed_records, presence: true, length: { maximum: 10 }, allow_blank: true - scope :priority_order, -> (column_to_sort = :last_incremental_run_at) { order(arel_table[column_to_sort].asc.nulls_first) } + scope :priority_order, ->(column_to_sort = :last_incremental_run_at) { order(arel_table[column_to_sort].asc.nulls_first) } scope :enabled, -> { where('enabled IS TRUE') } - # These columns were added with wrong naming convention, the columns were never used. - ignore_column :last_full_run_processed_records, remove_with: '15.1', remove_after: '2022-05-22' - ignore_column :last_full_run_runtimes_in_seconds, remove_with: '15.1', remove_after: '2022-05-22' - ignore_column :last_full_run_issues_updated_at, remove_with: '15.1', remove_after: '2022-05-22' - ignore_column :last_full_run_mrs_updated_at, remove_with: '15.1', remove_after: '2022-05-22' - ignore_column :last_full_run_issues_id, remove_with: '15.1', remove_after: '2022-05-22' - ignore_column :last_full_run_merge_requests_id, remove_with: '15.1', remove_after: '2022-05-22' - def cursor_for(mode, model) { updated_at: self["last_#{mode}_#{model.table_name}_updated_at"], diff --git a/app/models/analytics/usage_trends/measurement.rb b/app/models/analytics/usage_trends/measurement.rb index 02e239ca0ef..c1245d8dce7 100644 --- a/app/models/analytics/usage_trends/measurement.rb +++ b/app/models/analytics/usage_trends/measurement.rb @@ -23,9 +23,9 @@ module Analytics validates :recorded_at, uniqueness: { scope: :identifier } scope :order_by_latest, -> { order(recorded_at: :desc) } - scope :with_identifier, -> (identifier) { where(identifier: identifier) } - scope :recorded_after, -> (date) { where(self.model.arel_table[:recorded_at].gteq(date)) if date.present? } - scope :recorded_before, -> (date) { where(self.model.arel_table[:recorded_at].lteq(date)) if date.present? } + scope :with_identifier, ->(identifier) { where(identifier: identifier) } + scope :recorded_after, ->(date) { where(self.model.arel_table[:recorded_at].gteq(date)) if date.present? } + scope :recorded_before, ->(date) { where(self.model.arel_table[:recorded_at].lteq(date)) if date.present? } def self.identifier_query_mapping { diff --git a/app/models/appearance.rb b/app/models/appearance.rb index bd948c2c32a..4a046b3ab20 100644 --- a/app/models/appearance.rb +++ b/app/models/appearance.rb @@ -3,10 +3,10 @@ class Appearance < ApplicationRecord include CacheableAttributes include CacheMarkdownField - include ObjectStorage::BackgroundMove include WithUploads attribute :title, default: '' + attribute :short_title, default: '' attribute :description, default: '' attribute :new_project_guidelines, default: '' attribute :profile_image_guidelines, default: '' diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index adbbddd635c..3fb1f58f3e0 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -11,6 +11,7 @@ class ApplicationSetting < ApplicationRecord ignore_columns %i[elasticsearch_shards elasticsearch_replicas], remove_with: '14.4', remove_after: '2021-09-22' ignore_columns %i[static_objects_external_storage_auth_token], remove_with: '14.9', remove_after: '2022-03-22' ignore_column :user_email_lookup_limit, remove_with: '15.0', remove_after: '2022-04-18' + ignore_column :send_user_confirmation_email, remove_with: '15.8', remove_after: '2022-12-18' INSTANCE_REVIEW_MIN_USERS = 50 GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \ @@ -20,7 +21,7 @@ class ApplicationSetting < ApplicationRecord 'Admin Area > Settings > General > Kroki' enum whats_new_variant: { all_tiers: 0, current_tier: 1, disabled: 2 }, _prefix: true - enum email_confirmation_setting: { off: 0, soft: 1, hard: 2 } + enum email_confirmation_setting: { off: 0, soft: 1, hard: 2 }, _prefix: true 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 @@ -87,7 +88,7 @@ class ApplicationSetting < ApplicationRecord validates :grafana_url, system_hook_url: { - blocked_message: "is blocked: %{exception_message}. " + GRAFANA_URL_ERROR_MESSAGE + blocked_message: "is blocked: %{exception_message}. #{GRAFANA_URL_ERROR_MESSAGE}" }, if: :grafana_url_absolute? @@ -226,6 +227,10 @@ class ApplicationSetting < ApplicationRecord presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :max_terraform_state_size_bytes, + presence: true, + numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :default_artifacts_expire_in, presence: true, duration: true validates :container_expiration_policies_enable_historic_entries, @@ -412,12 +417,10 @@ class ApplicationSetting < ApplicationRecord allow_nil: false, inclusion: { in: [true, false], message: N_('must be a boolean value') } - # rubocop:disable Cop/StaticTranslationDefinition validates :deactivate_dormant_users_period, presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 90, message: _("'%{value}' days of inactivity must be greater than or equal to 90") }, + numericality: { only_integer: true, greater_than_or_equal_to: 90, message: N_("'%{value}' days of inactivity must be greater than or equal to 90") }, if: :deactivate_dormant_users? - # rubocop:enable Cop/StaticTranslationDefinition Gitlab::SSHPublicKey.supported_types.each do |type| validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type } @@ -466,7 +469,7 @@ class ApplicationSetting < ApplicationRecord validates :external_auth_client_key, presence: true, - if: -> (setting) { setting.external_auth_client_cert.present? } + if: ->(setting) { setting.external_auth_client_cert.present? } validates :lets_encrypt_notification_email, devise_email: true, @@ -488,17 +491,17 @@ class ApplicationSetting < ApplicationRecord validates :eks_access_key_id, length: { in: 16..128 }, - if: -> (setting) { setting.eks_integration_enabled? && setting.eks_access_key_id.present? } + if: ->(setting) { setting.eks_integration_enabled? && setting.eks_access_key_id.present? } validates :eks_secret_access_key, presence: true, - if: -> (setting) { setting.eks_integration_enabled? && setting.eks_access_key_id.present? } + if: ->(setting) { setting.eks_integration_enabled? && setting.eks_access_key_id.present? } validates_with X509CertificateCredentialsValidator, certificate: :external_auth_client_cert, pkey: :external_auth_client_key, pass: :external_auth_client_key_pass, - if: -> (setting) { setting.external_auth_client_cert.present? } + if: ->(setting) { setting.external_auth_client_cert.present? } validates :default_ci_config_path, format: { without: %r{(\.{2}|\A/)}, @@ -687,6 +690,10 @@ class ApplicationSetting < ApplicationRecord validates :disable_admin_oauth_scopes, inclusion: { in: [true, false], message: N_('must be a boolean value') } + validates :bulk_import_enabled, + allow_nil: false, + inclusion: { in: [true, false], message: N_('must be a boolean value') } + before_validation :ensure_uuid! before_validation :coerce_repository_storages_weighted, if: :repository_storages_weighted_changed? before_validation :normalize_default_branch_name diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 308c05d638c..229c4e68d79 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -76,6 +76,7 @@ module ApplicationSettingImplementation eks_account_id: nil, eks_integration_enabled: false, eks_secret_access_key: nil, + email_confirmation_setting: 'off', email_restrictions_enabled: false, email_restrictions: nil, external_pipeline_validation_service_timeout: nil, @@ -113,6 +114,7 @@ module ApplicationSettingImplementation max_attachment_size: Settings.gitlab['max_attachment_size'], max_export_size: 0, max_import_size: 0, + max_terraform_state_size_bytes: 0, max_yaml_size_bytes: 1.megabyte, max_yaml_depth: 100, minimum_password_length: DEFAULT_MINIMUM_PASSWORD_LENGTH, @@ -146,7 +148,6 @@ module ApplicationSettingImplementation require_two_factor_authentication: false, restricted_visibility_levels: Settings.gitlab['restricted_visibility_levels'], rsa_key_restriction: default_min_key_size(:rsa), - send_user_confirmation_email: false, session_expire_delay: Settings.gitlab['session_expire_delay'], shared_runners_enabled: Settings.gitlab_ci['shared_runners_enabled'], shared_runners_text: nil, @@ -243,7 +244,8 @@ module ApplicationSettingImplementation search_rate_limit_unauthenticated: 10, users_get_by_id_limit: 300, users_get_by_id_limit_allowlist: [], - can_create_group: true + can_create_group: true, + bulk_import_enabled: false } end diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 0ad17cd8869..5cc87be388f 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -28,11 +28,11 @@ class AuditEvent < ApplicationRecord validates :entity_type, presence: true validates :ip_address, ip_address: true - scope :by_entity_type, -> (entity_type) { where(entity_type: entity_type) } - scope :by_entity_id, -> (entity_id) { where(entity_id: entity_id) } - scope :by_author_id, -> (author_id) { where(author_id: author_id) } - scope :by_entity_username, -> (username) { where(entity_id: find_user_id(username)) } - scope :by_author_username, -> (username) { where(author_id: find_user_id(username)) } + scope :by_entity_type, ->(entity_type) { where(entity_type: entity_type) } + scope :by_entity_id, ->(entity_id) { where(entity_id: entity_id) } + scope :by_author_id, ->(author_id) { where(author_id: author_id) } + scope :by_entity_username, ->(username) { where(entity_id: find_user_id(username)) } + scope :by_author_username, ->(username) { where(author_id: find_user_id(username)) } after_initialize :initialize_details diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb index e9530a80d9f..f41f0a8be84 100644 --- a/app/models/award_emoji.rb +++ b/app/models/award_emoji.rb @@ -23,11 +23,11 @@ class AwardEmoji < ApplicationRecord scope :downvotes, -> { named(DOWNVOTE_NAME) } scope :upvotes, -> { named(UPVOTE_NAME) } - scope :named, -> (names) { where(name: names) } - scope :awarded_by, -> (users) { where(user: users) } + scope :named, ->(names) { where(name: names) } + scope :awarded_by, ->(users) { where(user: users) } - after_save :expire_cache after_destroy :expire_cache + after_save :expire_cache class << self def votes_for_collection(ids, type) diff --git a/app/models/badge.rb b/app/models/badge.rb index 4339d419b48..0676de10d02 100644 --- a/app/models/badge.rb +++ b/app/models/badge.rb @@ -8,6 +8,8 @@ class Badge < ApplicationRecord # the placeholder is found. PLACEHOLDERS = { 'project_path' => :full_path, + 'project_title' => :title, + 'project_name' => :path, 'project_id' => :id, 'default_branch' => :default_branch, 'commit_sha' => ->(project) { project.commit&.sha } diff --git a/app/models/blob_viewer/metrics_dashboard_yml.rb b/app/models/blob_viewer/metrics_dashboard_yml.rb index cac6b2192d0..4b7a178566c 100644 --- a/app/models/blob_viewer/metrics_dashboard_yml.rb +++ b/app/models/blob_viewer/metrics_dashboard_yml.rb @@ -25,11 +25,7 @@ module BlobViewer private def parse_blob_data - if Feature.enabled?(:metrics_dashboard_exhaustive_validations, project) - exhaustive_metrics_dashboard_validation - else - old_metrics_dashboard_validation - end + old_metrics_dashboard_validation end def old_metrics_dashboard_validation @@ -41,14 +37,5 @@ module BlobViewer rescue ActiveModel::ValidationError => e e.model.errors.messages.map { |messages| messages.join(': ') } end - - def exhaustive_metrics_dashboard_validation - yaml = ::Gitlab::Config::Loader::Yaml.new(blob.data).load_raw! - Gitlab::Metrics::Dashboard::Validator - .errors(yaml, dashboard_path: blob.path, project: project) - .map(&:message) - rescue Gitlab::Config::Loader::FormatError => e - [e.message] - end end end diff --git a/app/models/board_group_recent_visit.rb b/app/models/board_group_recent_visit.rb index dc273e256a8..65299d6dd12 100644 --- a/app/models/board_group_recent_visit.rb +++ b/app/models/board_group_recent_visit.rb @@ -12,7 +12,7 @@ class BoardGroupRecentVisit < ApplicationRecord validates :group, presence: true validates :board, presence: true - scope :by_user_parent, -> (user, group) { where(user: user, group: group) } + scope :by_user_parent, ->(user, group) { where(user: user, group: group) } def self.board_parent_relation :group diff --git a/app/models/board_project_recent_visit.rb b/app/models/board_project_recent_visit.rb index 723afd6feab..c5122392b91 100644 --- a/app/models/board_project_recent_visit.rb +++ b/app/models/board_project_recent_visit.rb @@ -12,7 +12,7 @@ class BoardProjectRecentVisit < ApplicationRecord validates :project, presence: true validates :board, presence: true - scope :by_user_parent, -> (user, project) { where(user: user, project: project) } + scope :by_user_parent, ->(user, project) { where(user: user, project: project) } def self.board_parent_relation :project diff --git a/app/models/bulk_import.rb b/app/models/bulk_import.rb index 2200a66b3c2..2565ad5f2b8 100644 --- a/app/models/bulk_import.rb +++ b/app/models/bulk_import.rb @@ -17,7 +17,7 @@ class BulkImport < ApplicationRecord enum source_type: { gitlab: 0 } scope :stale, -> { where('created_at < ?', 8.hours.ago).where(status: [0, 1]) } - scope :order_by_created_at, -> (direction) { order(created_at: direction) } + scope :order_by_created_at, ->(direction) { order(created_at: direction) } state_machine :status, initial: :created do state :created, value: 0 diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb index a2542e669e1..e49c4e09a50 100644 --- a/app/models/bulk_imports/entity.rb +++ b/app/models/bulk_imports/entity.rb @@ -53,7 +53,7 @@ class BulkImports::Entity < ApplicationRecord scope :by_user_id, ->(user_id) { joins(:bulk_import).where(bulk_imports: { user_id: user_id }) } scope :stale, -> { where('created_at < ?', 8.hours.ago).where(status: [0, 1]) } scope :by_bulk_import_id, ->(bulk_import_id) { where(bulk_import_id: bulk_import_id) } - scope :order_by_created_at, -> (direction) { order(created_at: direction) } + scope :order_by_created_at, ->(direction) { order(created_at: direction) } alias_attribute :destination_slug, :destination_name diff --git a/app/models/bulk_imports/export_upload.rb b/app/models/bulk_imports/export_upload.rb index a9cba5119af..4304032b28c 100644 --- a/app/models/bulk_imports/export_upload.rb +++ b/app/models/bulk_imports/export_upload.rb @@ -3,7 +3,6 @@ module BulkImports class ExportUpload < ApplicationRecord include WithUploads - include ObjectStorage::BackgroundMove self.table_name = 'bulk_import_export_uploads' diff --git a/app/models/bulk_imports/tracker.rb b/app/models/bulk_imports/tracker.rb index 357f4629078..b04ef1cb7ae 100644 --- a/app/models/bulk_imports/tracker.rb +++ b/app/models/bulk_imports/tracker.rb @@ -26,7 +26,7 @@ class BulkImports::Tracker < ApplicationRecord entity_scope = where(bulk_import_entity_id: entity_id) next_stage_scope = entity_scope.with_status(:created).select('MIN(stage)') - entity_scope.where(stage: next_stage_scope) + entity_scope.where(stage: next_stage_scope).with_status(:created) } def self.stage_running?(entity_id, stage) diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index d6051d70503..662fb3cffa8 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -18,8 +18,11 @@ module Ci belongs_to :project belongs_to :trigger_request + + # To be removed upon :ci_bridge_remove_sourced_pipelines feature flag removal has_many :sourced_pipelines, class_name: "::Ci::Sources::Pipeline", - foreign_key: :source_job_id + foreign_key: :source_job_id, + inverse_of: :source_bridge has_one :downstream_pipeline, through: :sourced_pipeline, source: :pipeline @@ -86,8 +89,20 @@ module Ci end end + def sourced_pipelines + if Feature.enabled?(:ci_bridge_remove_sourced_pipelines, project) + raise 'Ci::Bridge does not have sourced_pipelines association' + end + + super + end + def has_downstream_pipeline? - sourced_pipelines.exists? + if Feature.enabled?(:ci_bridge_remove_sourced_pipelines, project) + sourced_pipeline.present? + else + sourced_pipelines.exists? + end end def downstream_pipeline_params diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index f44ba124fe2..7f42b21bc87 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -7,7 +7,6 @@ module Ci include Ci::Contextable include TokenAuthenticatable include AfterCommitQueue - include ObjectStorage::BackgroundMove include Presentable include Importable include Ci::HasRef @@ -47,7 +46,7 @@ module Ci # DELETE queries when the Ci::Build is destroyed. The next step is to remove `dependent: :destroy`. # Details: https://gitlab.com/gitlab-org/gitlab/-/issues/24644#note_689472685 has_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy, inverse_of: :job # rubocop:disable Cop/ActiveRecordDependent - has_many :job_variables, class_name: 'Ci::JobVariable', foreign_key: :job_id + has_many :job_variables, class_name: 'Ci::JobVariable', foreign_key: :job_id, inverse_of: :job has_many :sourced_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_job_id has_many :pages_deployments, inverse_of: :ci_build @@ -71,6 +70,7 @@ module Ci delegate :harbor_integration, to: :project delegate :trigger_short_token, to: :trigger_request, allow_nil: true delegate :ensure_persistent_ref, to: :pipeline + delegate :enable_debug_trace!, to: :metadata serialize :options # rubocop:disable Cop/ActiveRecordSerialize serialize :yaml_variables, Gitlab::Serializer::Ci::Variables # rubocop:disable Cop/ActiveRecordSerialize @@ -90,7 +90,7 @@ module Ci scope :with_downloadable_artifacts, -> do where('EXISTS (?)', Ci::JobArtifact.select(1) - .where('ci_builds.id = ci_job_artifacts.job_id') + .where("#{Ci::Build.quoted_table_name}.id = #{Ci::JobArtifact.quoted_table_name}.job_id") .where(file_type: Ci::JobArtifact::DOWNLOADABLE_TYPES) ) end @@ -98,7 +98,7 @@ module Ci scope :with_erasable_artifacts, -> do where('EXISTS (?)', Ci::JobArtifact.select(1) - .where('ci_builds.id = ci_job_artifacts.job_id') + .where("#{Ci::Build.quoted_table_name}.id = #{Ci::JobArtifact.quoted_table_name}.job_id") .where(file_type: Ci::JobArtifact.erasable_file_types) ) end @@ -108,11 +108,11 @@ module Ci end scope :with_existing_job_artifacts, ->(query) do - where('EXISTS (?)', ::Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').merge(query)) + where('EXISTS (?)', ::Ci::JobArtifact.select(1).where("#{Ci::Build.quoted_table_name}.id = #{Ci::JobArtifact.quoted_table_name}.job_id").merge(query)) end scope :without_archived_trace, -> do - where('NOT EXISTS (?)', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').trace) + where('NOT EXISTS (?)', Ci::JobArtifact.select(1).where("#{Ci::Build.quoted_table_name}.id = #{Ci::JobArtifact.quoted_table_name}.job_id").trace) end scope :with_artifacts, ->(artifact_scope) do @@ -155,7 +155,7 @@ module Ci scope :manual_actions, -> { where(when: :manual, status: COMPLETED_STATUSES + %i[manual]) } scope :scheduled_actions, -> { where(when: :delayed, status: COMPLETED_STATUSES + %i[scheduled]) } scope :ref_protected, -> { where(protected: true) } - scope :with_live_trace, -> { where('EXISTS (?)', Ci::BuildTraceChunk.where('ci_builds.id = ci_build_trace_chunks.build_id').select(1)) } + scope :with_live_trace, -> { where('EXISTS (?)', Ci::BuildTraceChunk.where("#{quoted_table_name}.id = #{Ci::BuildTraceChunk.quoted_table_name}.build_id").select(1)) } scope :with_stale_live_trace, -> { with_live_trace.finished_before(12.hours.ago) } scope :finished_before, -> (date) { finished.where('finished_at < ?', date) } scope :license_management_jobs, -> { where(name: %i(license_management license_scanning)) } # handle license rename https://gitlab.com/gitlab-org/gitlab/issues/8911 @@ -172,8 +172,6 @@ module Ci add_authentication_token_field :token, encrypted: :required - before_save :ensure_token, unless: :assign_token_on_scheduling? - after_save :stick_build_if_status_changed after_create unless: :importing? do |build| @@ -247,11 +245,8 @@ module Ci !build.waiting_for_deployment_approval? # If false is returned, it stops the transition end - before_transition any => [:pending] do |build, transition| - if build.assign_token_on_scheduling? - build.ensure_token - end - + before_transition any => [:pending] do |build| + build.ensure_token true end @@ -419,12 +414,12 @@ module Ci end def waiting_for_deployment_approval? - manual? && starts_environment? && deployment&.blocked? + manual? && deployment_job? && deployment&.blocked? end def outdated_deployment? strong_memoize(:outdated_deployment) do - starts_environment? && + deployment_job? && incomplete? && project.ci_forward_deployment_enabled? && deployment&.older_than_last_successful_deployment? @@ -528,7 +523,7 @@ module Ci environment.present? end - def starts_environment? + def deployment_job? has_environment_keyword? && self.environment_action == 'start' end @@ -722,7 +717,7 @@ module Ci end def ensure_trace_metadata! - Ci::BuildTraceMetadata.find_or_upsert_for!(id) + Ci::BuildTraceMetadata.find_or_upsert_for!(id, partition_id) end def artifacts_expose_as @@ -866,6 +861,10 @@ module Ci Gitlab::Ci::Build::Step.from_after_script(self)].compact end + def runtime_hooks + Gitlab::Ci::Build::Hook.from_hooks(self) + end + def image Gitlab::Ci::Build::Image.from_image(self) end @@ -995,7 +994,7 @@ module Ci # Virtual deployment status depending on the environment status. def deployment_status - return unless starts_environment? + return unless deployment_job? if success? return successful_deployment_status @@ -1136,8 +1135,15 @@ module Ci end end - def assign_token_on_scheduling? - ::Feature.enabled?(:ci_assign_job_token_on_scheduling, project) + def partition_id_token_prefix + partition_id.to_s(16) if Feature.enabled?(:ci_build_partition_id_token_prefix, project) + end + + override :format_token + def format_token(token) + return token if partition_id_token_prefix.nil? + + "#{partition_id_token_prefix}_#{token}" end protected @@ -1208,11 +1214,11 @@ module Ci if project.ci_cd_settings.opt_in_jwt? id_tokens_variables else - legacy_jwt_variables.concat(id_tokens_variables) + predefined_jwt_variables.concat(id_tokens_variables) end end - def legacy_jwt_variables + def predefined_jwt_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| jwt = Gitlab::Ci::Jwt.for_build(self) jwt_v2 = Gitlab::Ci::JwtV2.for_build(self) @@ -1229,7 +1235,7 @@ module Ci Gitlab::Ci::Variables::Collection.new.tap do |variables| id_tokens.each do |var_name, token_data| - token = Gitlab::Ci::JwtV2.for_build(self, aud: token_data['id_token']['aud']) + token = Gitlab::Ci::JwtV2.for_build(self, aud: token_data['aud']) variables.append(key: var_name, value: token, public: false, masked: true) end diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb index 2f28509f812..9b4794abb2e 100644 --- a/app/models/ci/build_metadata.rb +++ b/app/models/ci/build_metadata.rb @@ -5,21 +5,16 @@ module Ci # Data that should be persisted forever, should be stored with Ci::Build model. class BuildMetadata < Ci::ApplicationRecord BuildTimeout = Struct.new(:value, :source) - ROUTING_FEATURE_FLAG = :ci_partitioning_use_ci_builds_metadata_routing_table include Ci::Partitionable include Presentable include ChronicDurationAttribute include Gitlab::Utils::StrongMemoize - self.table_name = 'ci_builds_metadata' + self.table_name = 'p_ci_builds_metadata' self.primary_key = 'id' - self.sequence_name = 'ci_builds_metadata_id_seq' - partitionable scope: :build, through: { - table: :p_ci_builds_metadata, - flag: ROUTING_FEATURE_FLAG - } + partitionable scope: :build belongs_to :build, class_name: 'CommitStatus' belongs_to :project @@ -63,6 +58,12 @@ module Ci runtime_runner_features[:cancel_gracefully] == true end + def enable_debug_trace! + self.debug_trace_enabled = true + save! if changes.any? + true + end + private def set_build_project diff --git a/app/models/ci/build_need.rb b/app/models/ci/build_need.rb index d4cbbfac4ab..3fa17d6d286 100644 --- a/app/models/ci/build_need.rb +++ b/app/models/ci/build_need.rb @@ -2,15 +2,18 @@ module Ci class BuildNeed < Ci::ApplicationRecord + include Ci::Partitionable include BulkInsertSafe belongs_to :build, class_name: "Ci::Processable", foreign_key: :build_id, inverse_of: :needs + partitionable scope: :build + validates :build, presence: true validates :name, presence: true, length: { maximum: 128 } validates :optional, inclusion: { in: [true, false] } - scope :scoped_build, -> { where('ci_builds.id=ci_build_needs.build_id') } + scope :scoped_build, -> { where("#{Ci::Build.quoted_table_name}.id = #{quoted_table_name}.build_id") } scope :artifacts, -> { where(artifacts: true) } end end diff --git a/app/models/ci/build_pending_state.rb b/app/models/ci/build_pending_state.rb index 53cf0697e2e..3684dac06c7 100644 --- a/app/models/ci/build_pending_state.rb +++ b/app/models/ci/build_pending_state.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true class Ci::BuildPendingState < Ci::ApplicationRecord + include Ci::Partitionable + belongs_to :build, class_name: 'Ci::Build', foreign_key: :build_id + partitionable scope: :build + enum state: Ci::Stage.statuses enum failure_reason: CommitStatus.failure_reasons diff --git a/app/models/ci/build_report_result.rb b/app/models/ci/build_report_result.rb index b674c1b1a0e..b2d99fab295 100644 --- a/app/models/ci/build_report_result.rb +++ b/app/models/ci/build_report_result.rb @@ -2,11 +2,15 @@ module Ci class BuildReportResult < Ci::ApplicationRecord + include Ci::Partitionable + self.primary_key = :build_id belongs_to :build, class_name: "Ci::Build", inverse_of: :report_results belongs_to :project, class_name: "Project", inverse_of: :build_report_results + partitionable scope: :build + validates :build, :project, presence: true validates :data, json_schema: { filename: "build_report_result_data" } diff --git a/app/models/ci/build_runner_session.rb b/app/models/ci/build_runner_session.rb index 0f37ce70964..20c0b04e228 100644 --- a/app/models/ci/build_runner_session.rb +++ b/app/models/ci/build_runner_session.rb @@ -4,6 +4,8 @@ module Ci # The purpose of this class is to store Build related runner session. # Data will be removed after transitioning from running to any state. class BuildRunnerSession < Ci::ApplicationRecord + include Ci::Partitionable + TERMINAL_SUBPROTOCOL = 'terminal.gitlab.com' DEFAULT_SERVICE_NAME = 'build' DEFAULT_PORT_NAME = 'default_port' @@ -12,6 +14,8 @@ module Ci belongs_to :build, class_name: 'Ci::Build', inverse_of: :runner_session + partitionable scope: :build + validates :build, presence: true validates :url, public_url: { schemes: %w(https) } diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb index 7baa98b59f9..57d8b9ba368 100644 --- a/app/models/ci/build_trace_chunk.rb +++ b/app/models/ci/build_trace_chunk.rb @@ -2,6 +2,7 @@ module Ci class BuildTraceChunk < Ci::ApplicationRecord + include Ci::Partitionable include ::Comparable include ::FastDestroyAll include ::Checksummable @@ -10,6 +11,8 @@ module Ci belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id + partitionable scope: :build + attribute :data_store, default: :redis_trace_chunks after_create { metrics.increment_trace_operation(operation: :chunked) } @@ -28,8 +31,8 @@ module Ci redis_trace_chunks: 4 }.freeze - STORE_TYPES = DATA_STORES.keys.to_h do |store| - [store, "Ci::BuildTraceChunks::#{store.to_s.camelize}".constantize] + STORE_TYPES = DATA_STORES.keys.index_with do |store| + "Ci::BuildTraceChunks::#{store.to_s.camelize}".constantize end.freeze LIVE_STORES = %i[redis redis_trace_chunks].freeze diff --git a/app/models/ci/build_trace_metadata.rb b/app/models/ci/build_trace_metadata.rb index 86de90983ff..00cf1531483 100644 --- a/app/models/ci/build_trace_metadata.rb +++ b/app/models/ci/build_trace_metadata.rb @@ -2,6 +2,8 @@ module Ci class BuildTraceMetadata < Ci::ApplicationRecord + include Ci::Partitionable + MAX_ATTEMPTS = 5 self.table_name = 'ci_build_trace_metadata' self.primary_key = :build_id @@ -9,15 +11,17 @@ module Ci belongs_to :build, class_name: 'Ci::Build' belongs_to :trace_artifact, class_name: 'Ci::JobArtifact' + partitionable scope: :build + validates :build, presence: true validates :archival_attempts, presence: true - def self.find_or_upsert_for!(build_id) - record = find_by(build_id: build_id) + def self.find_or_upsert_for!(build_id, partition_id) + record = find_by(build_id: build_id, partition_id: partition_id) return record if record - upsert({ build_id: build_id }, unique_by: :build_id) - find_by!(build_id: build_id) + upsert({ build_id: build_id, partition_id: partition_id }, unique_by: :build_id) + find_by!(build_id: build_id, partition_id: partition_id) end # The job is retried around 5 times during the 7 days retention period for diff --git a/app/models/ci/freeze_period.rb b/app/models/ci/freeze_period.rb index da0bbbacddd..1bf32e04a15 100644 --- a/app/models/ci/freeze_period.rb +++ b/app/models/ci/freeze_period.rb @@ -4,6 +4,10 @@ module Ci class FreezePeriod < Ci::ApplicationRecord include StripAttribute include Ci::NamespacedModelName + include Gitlab::Utils::StrongMemoize + + STATUS_ACTIVE = :active + STATUS_INACTIVE = :inactive default_scope { order(created_at: :asc) } # rubocop:disable Cop/DefaultScope @@ -14,5 +18,60 @@ module Ci validates :freeze_start, cron: true, presence: true validates :freeze_end, cron: true, presence: true validates :cron_timezone, cron_freeze_period_timezone: true, presence: true + + def active? + status == STATUS_ACTIVE + end + + def status + Gitlab::SafeRequestStore.fetch("ci:freeze_period:#{id}:status") do + within_freeze_period? ? STATUS_ACTIVE : STATUS_INACTIVE + end + end + + def time_start + Gitlab::SafeRequestStore.fetch("ci:freeze_period:#{id}:time_start") do + freeze_start_parsed_cron.previous_time_from(time_zone_now) + end + end + + def next_time_start + Gitlab::SafeRequestStore.fetch("ci:freeze_period:#{id}:next_time_start") do + freeze_start_parsed_cron.next_time_from(time_zone_now) + end + end + + def time_end_from_now + Gitlab::SafeRequestStore.fetch("ci:freeze_period:#{id}:time_end_from_now") do + freeze_end_parsed_cron.next_time_from(time_zone_now) + end + end + + def time_end_from_start + Gitlab::SafeRequestStore.fetch("ci:freeze_period:#{id}:time_end_from_start") do + freeze_end_parsed_cron.next_time_from(time_start) + end + end + + private + + def within_freeze_period? + time_start <= time_zone_now && time_zone_now <= time_end_from_start + end + + def freeze_start_parsed_cron + Gitlab::Ci::CronParser.new(freeze_start, cron_timezone) + end + strong_memoize_attr :freeze_start_parsed_cron + + def freeze_end_parsed_cron + Gitlab::Ci::CronParser.new(freeze_end, cron_timezone) + end + strong_memoize_attr :freeze_end_parsed_cron + + def time_zone_now + Time.zone.now + end + strong_memoize_attr :time_zone_now end end diff --git a/app/models/ci/freeze_period_status.rb b/app/models/ci/freeze_period_status.rb deleted file mode 100644 index e810bb3f229..00000000000 --- a/app/models/ci/freeze_period_status.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -module Ci - class FreezePeriodStatus - attr_reader :project - - def initialize(project:) - @project = project - end - - def execute - project.freeze_periods.any? { |period| within_freeze_period?(period) } - end - - def within_freeze_period?(period) - start_freeze_cron = Gitlab::Ci::CronParser.new(period.freeze_start, period.cron_timezone) - end_freeze_cron = Gitlab::Ci::CronParser.new(period.freeze_end, period.cron_timezone) - - start_freeze = start_freeze_cron.previous_time_from(time_zone_now) - end_freeze = end_freeze_cron.next_time_from(start_freeze) - - start_freeze <= time_zone_now && time_zone_now <= end_freeze - end - - private - - def time_zone_now - @time_zone_now ||= Time.zone.now - end - end -end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 922806a21c3..53c358f4eba 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -5,7 +5,6 @@ module Ci include Ci::Partitionable include IgnorableColumns include AfterCommitQueue - include ObjectStorage::BackgroundMove include UpdateProjectStatistics include UsageStatistics include Sortable @@ -52,7 +51,8 @@ module Ci cobertura: 'cobertura-coverage.xml', terraform: 'tfplan.json', cluster_applications: 'gl-cluster-applications.json', # DEPRECATED: https://gitlab.com/gitlab-org/gitlab/-/issues/361094 - requirements: 'requirements.json', + requirements: 'requirements.json', # Will be DEPRECATED soon: https://gitlab.com/groups/gitlab-org/-/epics/9203 + requirements_v2: 'requirements_v2.json', coverage_fuzzing: 'gl-coverage-fuzzing.json', api_fuzzing: 'gl-api-fuzzing-report.json', cyclonedx: 'gl-sbom.cdx.json' @@ -95,6 +95,7 @@ module Ci load_performance: :raw, terraform: :raw, requirements: :raw, + requirements_v2: :raw, coverage_fuzzing: :raw, api_fuzzing: :raw }.freeze @@ -119,6 +120,7 @@ module Ci sast secret_detection requirements + requirements_v2 cluster_image_scanning cyclonedx ].freeze @@ -209,7 +211,8 @@ module Ci load_performance: 25, ## EE-specific api_fuzzing: 26, ## EE-specific cluster_image_scanning: 27, ## EE-specific - cyclonedx: 28 ## EE-specific + cyclonedx: 28, ## EE-specific + requirements_v2: 29 ## EE-specific } # `file_location` indicates where actual files are stored. diff --git a/app/models/ci/job_token/allowlist.rb b/app/models/ci/job_token/allowlist.rb new file mode 100644 index 00000000000..9e9a0a68ebd --- /dev/null +++ b/app/models/ci/job_token/allowlist.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true +module Ci + module JobToken + class Allowlist + def initialize(source_project, direction:) + @source_project = source_project + @direction = direction + end + + def includes?(target_project) + source_links + .with_target(target_project) + .exists? + end + + def projects + Project.from_union(target_projects, remove_duplicates: false) + end + + private + + def source_links + Ci::JobToken::ProjectScopeLink + .with_source(@source_project) + .where(direction: @direction) + end + + def target_project_ids + source_links + # pluck needed to avoid ci and main db join + .pluck(:target_project_id) + end + + def target_projects + [ + Project.id_in(@source_project), + Project.id_in(target_project_ids) + ] + end + end + end +end diff --git a/app/models/ci/job_token/project_scope_link.rb b/app/models/ci/job_token/project_scope_link.rb index 3fdf07123e6..b784f93651a 100644 --- a/app/models/ci/job_token/project_scope_link.rb +++ b/app/models/ci/job_token/project_scope_link.rb @@ -12,8 +12,8 @@ module Ci belongs_to :target_project, class_name: 'Project' belongs_to :added_by, class_name: 'User' - scope :from_project, ->(project) { where(source_project: project) } - scope :to_project, ->(project) { where(target_project: project) } + scope :with_source, ->(project) { where(source_project: project) } + scope :with_target, ->(project) { where(target_project: project) } validates :source_project, presence: true validates :target_project, presence: true diff --git a/app/models/ci/job_token/scope.rb b/app/models/ci/job_token/scope.rb index 1aa49b95201..e320c0f92d1 100644 --- a/app/models/ci/job_token/scope.rb +++ b/app/models/ci/job_token/scope.rb @@ -1,49 +1,58 @@ # frozen_string_literal: true -# This model represents the surface where a CI_JOB_TOKEN can be used. -# A Scope is initialized with the project that the job token belongs to, -# and indicates what are all the other projects that the token could access. +# This model represents the scope of access for a CI_JOB_TOKEN. # -# By default a job token can only access its own project, which is the same -# project that defines the scope. -# By adding ScopeLinks to the scope we can allow other projects to be accessed -# by the job token. This works as an allowlist of projects for a job token. +# A scope is initialized with a project. +# +# Projects can be added to the scope by adding ScopeLinks to +# create an allowlist of projects in either access direction (inbound, outbound). +# +# Currently, projects in the outbound allowlist can be accessed via the token +# in the source project. +# +# TODO(Issue #346298) Projects in the inbound allowlist can use their token to access +# the source project. +# +# CI_JOB_TOKEN should be considered untrusted without these features enabled. # -# If a project is not included in the scope we should not allow the job user -# to access it since operations using CI_JOB_TOKEN should be considered untrusted. module Ci module JobToken class Scope - attr_reader :source_project + attr_reader :current_project - def initialize(project) - @source_project = project + def initialize(current_project) + @current_project = current_project end - def includes?(target_project) - # if the setting is disabled any project is considered to be in scope. - return true unless source_project.ci_outbound_job_token_scope_enabled? + def allows?(accessed_project) + self_referential?(accessed_project) || outbound_allows?(accessed_project) + end - target_project.id == source_project.id || - Ci::JobToken::ProjectScopeLink.from_project(source_project).to_project(target_project).exists? + def outbound_projects + outbound_allowlist.projects end + # Deprecated: use outbound_projects, TODO(Issue #346298) remove references to all_project def all_projects - Project.from_union(target_projects, remove_duplicates: false) + outbound_projects end private - def target_project_ids - Ci::JobToken::ProjectScopeLink.from_project(source_project).pluck(:target_project_id) + def outbound_allows?(accessed_project) + # if the setting is disabled any project is considered to be in scope. + return true unless @current_project.ci_outbound_job_token_scope_enabled? + + outbound_allowlist.includes?(accessed_project) + end + + def outbound_allowlist + Ci::JobToken::Allowlist.new(@current_project, direction: :outbound) end - def target_projects - [ - Project.id_in(source_project), - Project.id_in(target_project_ids) - ] + def self_referential?(accessed_project) + @current_project.id == accessed_project.id end end end diff --git a/app/models/ci/job_variable.rb b/app/models/ci/job_variable.rb index 332a78b66ae..998f0647ad5 100644 --- a/app/models/ci/job_variable.rb +++ b/app/models/ci/job_variable.rb @@ -2,12 +2,15 @@ module Ci class JobVariable < Ci::ApplicationRecord + include Ci::Partitionable include Ci::NewHasVariable include Ci::RawVariable include BulkInsertSafe belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id + partitionable scope: :job + alias_attribute :secret_value, :value validates :key, uniqueness: { scope: :job_id }, unless: :dotenv_source? diff --git a/app/models/ci/pending_build.rb b/app/models/ci/pending_build.rb index 0fa6a234a3d..2b1eb67d4f2 100644 --- a/app/models/ci/pending_build.rb +++ b/app/models/ci/pending_build.rb @@ -3,11 +3,14 @@ module Ci class PendingBuild < Ci::ApplicationRecord include EachBatch + include Ci::Partitionable belongs_to :project belongs_to :build, class_name: 'Ci::Build' belongs_to :namespace, inverse_of: :pending_builds, class_name: 'Namespace' + partitionable scope: :build + validates :namespace, presence: true scope :ref_protected, -> { where(protected: true) } diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 020f5cf9d8e..05207fb1ca0 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -350,9 +350,13 @@ module Ci 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_id) { where(project_id: project_id) } + scope :for_name, -> (name) do + name_column = Ci::PipelineMetadata.arel_table[:name] + + joins(:pipeline_metadata).where(name_column.lower.eq(name.downcase)) + end scope :created_after, -> (time) { where(arel_table[:created_at].gt(time)) } scope :created_before_id, -> (id) { where(arel_table[:id].lt(id)) } scope :before_pipeline, -> (pipeline) { created_before_id(pipeline.id).outside_pipeline_family(pipeline) } @@ -721,7 +725,7 @@ module Ci def freeze_period? strong_memoize(:freeze_period) do - Ci::FreezePeriodStatus.new(project: project).execute + project.freeze_periods.any?(&:active?) end end @@ -1341,13 +1345,14 @@ module Ci persistent_ref.create end + # For dependent bridge jobs we reset the upstream bridge recursively + # to reflect that a downstream pipeline is running again def reset_source_bridge!(current_user) # break recursion when no source_pipeline bridge (first upstream pipeline) return unless bridge_waiting? return unless current_user.can?(:update_pipeline, source_bridge.pipeline) - source_bridge.pending! - Ci::AfterRequeueJobService.new(project, current_user).execute(source_bridge) # rubocop:disable CodeReuse/ServiceClass + Ci::EnqueueJobService.new(source_bridge, current_user: current_user).execute(&:pending!) # rubocop:disable CodeReuse/ServiceClass end # EE-only diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb index 96e5567e85e..20ff07e88ba 100644 --- a/app/models/ci/pipeline_schedule.rb +++ b/app/models/ci/pipeline_schedule.rb @@ -16,7 +16,7 @@ module Ci belongs_to :owner, class_name: 'User' has_one :last_pipeline, -> { order(id: :desc) }, class_name: 'Ci::Pipeline' has_many :pipelines - has_many :variables, class_name: 'Ci::PipelineScheduleVariable', validate: false + has_many :variables, class_name: 'Ci::PipelineScheduleVariable' validates :cron, unless: :importing?, cron: true, presence: { unless: :importing? } validates :cron_timezone, cron_timezone: true, presence: { unless: :importing? } @@ -78,8 +78,6 @@ module Ci ref.start_with? 'refs/tags/' end - private - def worker_cron_expression Settings.cron_jobs['pipeline_schedule_worker']['cron'] end diff --git a/app/models/ci/pipeline_schedule_variable.rb b/app/models/ci/pipeline_schedule_variable.rb index 718ed14edeb..00251ea06fd 100644 --- a/app/models/ci/pipeline_schedule_variable.rb +++ b/app/models/ci/pipeline_schedule_variable.rb @@ -9,6 +9,6 @@ module Ci alias_attribute :secret_value, :value - validates :key, uniqueness: { scope: :pipeline_schedule_id } + validates :key, presence: true, uniqueness: { scope: :pipeline_schedule_id } end end diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb index eb805ffae0a..37c82c125aa 100644 --- a/app/models/ci/processable.rb +++ b/app/models/ci/processable.rb @@ -104,8 +104,8 @@ module Ci to: :pipeline def clone(current_user:, new_job_variables_attributes: []) - new_attributes = self.class.clone_accessors.to_h do |attribute| - [attribute, public_send(attribute)] # rubocop:disable GitlabSecurity/PublicSend + new_attributes = self.class.clone_accessors.index_with do |attribute| + public_send(attribute) # rubocop:disable GitlabSecurity/PublicSend end if persisted_environment.present? diff --git a/app/models/ci/resource_group.rb b/app/models/ci/resource_group.rb index 6d25f747a9d..b788e4f58c1 100644 --- a/app/models/ci/resource_group.rb +++ b/app/models/ci/resource_group.rb @@ -24,11 +24,18 @@ module Ci # NOTE: This is concurrency-safe method that the subquery in the `UPDATE` # works as explicit locking. def assign_resource_to(processable) - resources.free.limit(1).update_all(build_id: processable.id) > 0 + attrs = { + build_id: processable.id, + partition_id: processable.partition_id + } + + resources.free.limit(1).update_all(attrs) > 0 end def release_resource_from(processable) - resources.retained_by(processable).update_all(build_id: nil) > 0 + attrs = { build_id: nil, partition_id: nil } + + resources.retained_by(processable).update_all(attrs) > 0 end def upcoming_processables diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 3be627989b1..a7f3ff938c3 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -89,6 +89,9 @@ module Ci scope :ordered, -> { order(id: :desc) } scope :with_recent_runner_queue, -> { where('contacted_at > ?', recent_queue_deadline) } + scope :with_running_builds, -> do + where('EXISTS(?)', ::Ci::Build.running.select(1).where('ci_builds.runner_id = ci_runners.id')) + end # BACKWARD COMPATIBILITY: There are needed to maintain compatibility with `AVAILABLE_SCOPES` used by `lib/api/runners.rb` scope :deprecated_shared, -> { instance_type } diff --git a/app/models/ci/runner_namespace.rb b/app/models/ci/runner_namespace.rb index 82390ccc538..502ceae3675 100644 --- a/app/models/ci/runner_namespace.rb +++ b/app/models/ci/runner_namespace.rb @@ -15,6 +15,8 @@ module Ci validates :runner_id, uniqueness: { scope: :namespace_id } validate :group_runner_type + scope :for_runner, ->(runner_id) { where(runner_id: runner_id) } + def recent_runners ::Ci::Runner.belonging_to_group(namespace_id).recent end diff --git a/app/models/ci/running_build.rb b/app/models/ci/running_build.rb index ae38d54862d..43214b0c336 100644 --- a/app/models/ci/running_build.rb +++ b/app/models/ci/running_build.rb @@ -1,7 +1,18 @@ # frozen_string_literal: true module Ci + # This model represents metadata for a running build. + # Despite the generic RunningBuild name, in this first iteration it applies only to shared runners + # (see Ci::RunningBuild.upsert_shared_runner_build!). + # The decision to insert all of the running builds here was deferred to avoid the pressure on the database as + # at this time that was not necessary. + # We can reconsider the decision to limit this only to shared runners when there is more evidence that inserting all + # of the running builds there is worth the additional pressure. class RunningBuild < Ci::ApplicationRecord + include Ci::Partitionable + + partitionable scope: :build + belongs_to :project belongs_to :build, class_name: 'Ci::Build' belongs_to :runner, class_name: 'Ci::Runner' diff --git a/app/models/ci/secure_file.rb b/app/models/ci/secure_file.rb index df38398e5a9..1e6c48bbef5 100644 --- a/app/models/ci/secure_file.rb +++ b/app/models/ci/secure_file.rb @@ -17,20 +17,19 @@ module Ci validates :file, presence: true, file_size: { maximum: FILE_SIZE_LIMIT } validates :checksum, :file_store, :name, :project_id, presence: true validates :name, uniqueness: { scope: :project } + + attribute :metadata, :ind_jsonb validates :metadata, json_schema: { filename: "ci_secure_file_metadata" }, allow_nil: true + attribute :file_store, default: -> { Ci::SecureFileUploader.default_store } + mount_file_store_uploader Ci::SecureFileUploader + after_initialize :generate_key_data before_validation :assign_checksum scope :order_by_created_at, -> { order(created_at: :desc) } scope :project_id_in, ->(ids) { where(project_id: ids) } - serialize :metadata, Serializers::Json # rubocop:disable Cop/ActiveRecordSerialize - - attribute :file_store, default: -> { Ci::SecureFileUploader.default_store } - - mount_file_store_uploader Ci::SecureFileUploader - def checksum_algorithm CHECKSUM_ALGORITHM end diff --git a/app/models/ci/sources/pipeline.rb b/app/models/ci/sources/pipeline.rb index 2df504cd3de..855e68d1db1 100644 --- a/app/models/ci/sources/pipeline.rb +++ b/app/models/ci/sources/pipeline.rb @@ -3,6 +3,7 @@ module Ci module Sources class Pipeline < Ci::ApplicationRecord + include Ci::Partitionable include Ci::NamespacedModelName self.table_name = "ci_sources_pipelines" @@ -15,6 +16,11 @@ module Ci belongs_to :source_bridge, class_name: "Ci::Bridge", foreign_key: :source_job_id belongs_to :source_pipeline, class_name: "Ci::Pipeline", foreign_key: :source_pipeline_id + partitionable scope: :pipeline + + before_validation :set_source_partition_id, on: :create + validates :source_partition_id, presence: true + validates :project, presence: true validates :pipeline, presence: true @@ -23,6 +29,15 @@ module Ci validates :source_pipeline, presence: true scope :same_project, -> { where(arel_table[:source_project_id].eq(arel_table[:project_id])) } + + private + + def set_source_partition_id + return if source_partition_id_changed? && source_partition_id.present? + return unless source_job + + self.source_partition_id = source_job.partition_id + end end end end diff --git a/app/models/ci/unit_test_failure.rb b/app/models/ci/unit_test_failure.rb index a5aa3b70e37..cfef1249164 100644 --- a/app/models/ci/unit_test_failure.rb +++ b/app/models/ci/unit_test_failure.rb @@ -2,6 +2,8 @@ module Ci class UnitTestFailure < Ci::ApplicationRecord + include Ci::Partitionable + REPORT_WINDOW = 14.days validates :unit_test, :build, :failed_at, presence: true @@ -9,6 +11,8 @@ module Ci belongs_to :unit_test, class_name: "Ci::UnitTest", foreign_key: :unit_test_id belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id + partitionable scope: :build + scope :deletable, -> { where('failed_at < ?', REPORT_WINDOW.ago) } def self.recent_failures_count(project:, unit_test_keys:, date_range: REPORT_WINDOW.ago..Time.current) diff --git a/app/models/clusters/agent_token.rb b/app/models/clusters/agent_token.rb index 1607d0b6d19..e2dcff13a69 100644 --- a/app/models/clusters/agent_token.rb +++ b/app/models/clusters/agent_token.rb @@ -25,5 +25,9 @@ module Clusters active: 0, revoked: 1 } + + def to_ability_name + :cluster + end end end diff --git a/app/models/commit.rb b/app/models/commit.rb index 54de45ebba7..5175842e5de 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -359,6 +359,10 @@ class Commit end def has_signature? + if signature_type == :SSH && !ssh_signatures_enabled? + return false + end + signature_type && signature_type != :NONE end @@ -378,6 +382,10 @@ class Commit @signature_type ||= raw_signature_type || :NONE end + def ssh_signatures_enabled? + Feature.enabled?(:ssh_commit_signatures, project) + end + def signature strong_memoize(:signature) do case signature_type @@ -385,6 +393,8 @@ class Commit gpg_commit.signature when :X509 Gitlab::X509::Commit.new(self).signature + when :SSH + Gitlab::Ssh::Commit.new(self).signature if ssh_signatures_enabled? else nil end diff --git a/app/models/commit_range.rb b/app/models/commit_range.rb index e2f0de52bc9..87029cb2033 100644 --- a/app/models/commit_range.rb +++ b/app/models/commit_range.rb @@ -148,7 +148,7 @@ class CommitRange def sha_start return unless sha_from - exclude_start? ? sha_from + '^' : sha_from + exclude_start? ? "#{sha_from}^" : sha_from end def commit_start diff --git a/app/models/commit_signatures/gpg_signature.rb b/app/models/commit_signatures/gpg_signature.rb index 2ae59853520..a9e8ca2dd33 100644 --- a/app/models/commit_signatures/gpg_signature.rb +++ b/app/models/commit_signatures/gpg_signature.rb @@ -2,6 +2,7 @@ module CommitSignatures class GpgSignature < ApplicationRecord include CommitSignature + include SignatureType sha_attribute :gpg_key_primary_keyid @@ -10,6 +11,14 @@ module CommitSignatures validates :gpg_key_primary_keyid, presence: true + def signed_by_user + gpg_key&.user + end + + def type + :gpg + end + def self.with_key_and_subkeys(gpg_key) subkey_ids = gpg_key.subkeys.pluck(:id) diff --git a/app/models/commit_signatures/ssh_signature.rb b/app/models/commit_signatures/ssh_signature.rb index 7a8d0653fcd..1e64e2b2978 100644 --- a/app/models/commit_signatures/ssh_signature.rb +++ b/app/models/commit_signatures/ssh_signature.rb @@ -3,7 +3,16 @@ module CommitSignatures class SshSignature < ApplicationRecord include CommitSignature + include SignatureType belongs_to :key, optional: true + + def type + :ssh + end + + def signed_by_user + key&.user + end end end diff --git a/app/models/commit_signatures/x509_commit_signature.rb b/app/models/commit_signatures/x509_commit_signature.rb index 2cbb331dd7e..4edbc147502 100644 --- a/app/models/commit_signatures/x509_commit_signature.rb +++ b/app/models/commit_signatures/x509_commit_signature.rb @@ -2,15 +2,24 @@ module CommitSignatures class X509CommitSignature < ApplicationRecord include CommitSignature + include SignatureType belongs_to :x509_certificate, class_name: 'X509Certificate', foreign_key: 'x509_certificate_id', optional: false validates :x509_certificate_id, presence: true + def type + :x509 + end + def x509_commit return unless commit Gitlab::X509::Commit.new(commit) end + + def signed_by_user + commit&.committer + end end end diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index b32502c3ee2..f419fa8518e 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -16,7 +16,6 @@ module Avatarable included do prepend ShadowMethods - include ObjectStorage::BackgroundMove include Gitlab::Utils::StrongMemoize include ApplicationHelper diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index ec0cf36d875..6a855198697 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -40,7 +40,7 @@ module CacheMarkdownField # Banzai is less strict about authors, so don't always have an author key context[:author] = self.author if self.respond_to?(:author) - context[:markdown_engine] = :common_mark + context[:markdown_engine] = Banzai::Filter::MarkdownFilter::DEFAULT_ENGINE if Feature.enabled?(:personal_snippet_reference_filters, context[:author]) context[:user] = self.parent_user diff --git a/app/models/concerns/cached_commit.rb b/app/models/concerns/cached_commit.rb index 183d5728743..0fb72552dd5 100644 --- a/app/models/concerns/cached_commit.rb +++ b/app/models/concerns/cached_commit.rb @@ -4,8 +4,8 @@ module CachedCommit extend ActiveSupport::Concern def to_hash - Gitlab::Git::Commit::SERIALIZE_KEYS.each_with_object({}) do |key, hash| - hash[key] = public_send(key) # rubocop:disable GitlabSecurity/PublicSend + Gitlab::Git::Commit::SERIALIZE_KEYS.index_with do |key| + public_send(key) # rubocop:disable GitlabSecurity/PublicSend end end diff --git a/app/models/concerns/ci/partitionable.rb b/app/models/concerns/ci/partitionable.rb index 68a6714c892..d6ba0f4488f 100644 --- a/app/models/concerns/ci/partitionable.rb +++ b/app/models/concerns/ci/partitionable.rb @@ -25,10 +25,21 @@ module Ci PARTITIONABLE_MODELS = %w[ CommitStatus Ci::BuildMetadata - Ci::Stage + Ci::BuildNeed + Ci::BuildReportResult + Ci::BuildRunnerSession + Ci::BuildTraceChunk + Ci::BuildTraceMetadata + Ci::BuildPendingState Ci::JobArtifact - Ci::PipelineVariable + Ci::JobVariable Ci::Pipeline + Ci::PendingBuild + Ci::RunningBuild + Ci::PipelineVariable + Ci::Sources::Pipeline + Ci::Stage + Ci::UnitTestFailure ].freeze def self.check_inclusion(klass) @@ -57,14 +68,31 @@ module Ci end class_methods do - def partitionable(scope:, through: nil) - if through - define_singleton_method(:routing_table_name) { through[:table] } - define_singleton_method(:routing_table_name_flag) { through[:flag] } + def partitionable(scope:, through: nil, partitioned: false) + handle_partitionable_through(through) + handle_partitionable_dml(partitioned) + handle_partitionable_scope(scope) + end - include Partitionable::Switch - end + private + + def handle_partitionable_through(options) + return unless options + + define_singleton_method(:routing_table_name) { options[:table] } + define_singleton_method(:routing_table_name_flag) { options[:flag] } + + include Partitionable::Switch + end + + def handle_partitionable_dml(partitioned) + define_singleton_method(:partitioned?) { partitioned } + return unless partitioned + + include Partitionable::PartitionedFilter + end + def handle_partitionable_scope(scope) define_method(:partition_scope_value) do strong_memoize(:partition_scope_value) do next Ci::Pipeline.current_partition_value if respond_to?(:importing?) && importing? diff --git a/app/models/concerns/ci/partitionable/partitioned_filter.rb b/app/models/concerns/ci/partitionable/partitioned_filter.rb new file mode 100644 index 00000000000..4adae3be26a --- /dev/null +++ b/app/models/concerns/ci/partitionable/partitioned_filter.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Ci + module Partitionable + # Used to patch the save, update, delete, destroy methods to use the + # partition_id attributes for their SQL queries. + module PartitionedFilter + extend ActiveSupport::Concern + + if Rails::VERSION::MAJOR >= 7 + # These methods are updated in Rails 7 to use `_primary_key_constraints_hash` + # by default, so this patch will no longer be required. + # + # rubocop:disable Gitlab/NoCodeCoverageComment + # :nocov: + raise "`#{__FILE__}` should be double checked" if Rails.env.test? + + warn "Update `#{__FILE__}`. Patches Rails internals for partitioning" + # :nocov: + # rubocop:enable Gitlab/NoCodeCoverageComment + else + def _update_row(attribute_names, attempted_action = "update") + self.class._update_record( + attributes_with_values(attribute_names), + _primary_key_constraints_hash + ) + end + + def _delete_row + self.class._delete_record(_primary_key_constraints_hash) + end + end + + # Introduced in Rails 7, but updated to include `partition_id` filter. + # https://github.com/rails/rails/blob/a4dbb153fd390ac31bb9808809e7ac4d3a2c5116/activerecord/lib/active_record/persistence.rb#L1031-L1033 + def _primary_key_constraints_hash + { @primary_key => id_in_database, partition_id: partition_id } # rubocop:disable Gitlab/ModuleWithInstanceVariables + end + end + end +end diff --git a/app/models/concerns/commit_signature.rb b/app/models/concerns/commit_signature.rb index 5bdfa9a2966..7f1fbbefd94 100644 --- a/app/models/concerns/commit_signature.rb +++ b/app/models/concerns/commit_signature.rb @@ -44,7 +44,7 @@ module CommitSignature project.commit(commit_sha) end - def user - commit.committer + def signed_by_user + raise NoMethodError, 'must implement `signed_by_user` method' end end diff --git a/app/models/concerns/counter_attribute.rb b/app/models/concerns/counter_attribute.rb index 03e062a9855..f1efbba67e1 100644 --- a/app/models/concerns/counter_attribute.rb +++ b/app/models/concerns/counter_attribute.rb @@ -17,14 +17,29 @@ # counter_attribute :storage_size # end # +# It's possible to define a conditional counter attribute. You need to pass a proc +# that must accept a single argument, the object instance on which this concern is +# included. +# +# @example: +# +# class ProjectStatistics +# include CounterAttribute +# +# counter_attribute :conditional_one, if: -> { |object| object.use_counter_attribute? } +# end +# # To increment the counter we can use the method: -# delayed_increment_counter(:commit_count, 3) +# increment_counter(:commit_count, 3) +# +# This method would determine whether it would increment the counter using Redis, +# or fallback to legacy increment on ActiveRecord counters. # # It is possible to register callbacks to be executed after increments have # been flushed to the database. Callbacks are not executed if there are no increments # to flush. # -# counter_attribute_after_flush do |statistic| +# counter_attribute_after_commit do |statistic| # Namespaces::ScheduleAggregationWorker.perform_async(statistic.namespace_id) # end # @@ -32,99 +47,51 @@ module CounterAttribute extend ActiveSupport::Concern extend AfterCommitQueue include Gitlab::ExclusiveLeaseHelpers - - LUA_STEAL_INCREMENT_SCRIPT = <<~EOS - local increment_key, flushed_key = KEYS[1], KEYS[2] - local increment_value = redis.call("get", increment_key) or 0 - local flushed_value = redis.call("incrby", flushed_key, increment_value) - if flushed_value == 0 then - redis.call("del", increment_key, flushed_key) - else - redis.call("del", increment_key) - end - return flushed_value - EOS - - WORKER_DELAY = 10.minutes - WORKER_LOCK_TTL = 10.minutes + include Gitlab::Utils::StrongMemoize class_methods do - def counter_attribute(attribute) - counter_attributes << attribute + def counter_attribute(attribute, if: nil) + counter_attributes << { + attribute: attribute, + if_proc: binding.local_variable_get(:if) # can't read `if` directly + } end def counter_attributes - @counter_attributes ||= Set.new + @counter_attributes ||= [] end - def after_flush_callbacks - @after_flush_callbacks ||= [] + def after_commit_callbacks + @after_commit_callbacks ||= [] end - # perform registered callbacks after increments have been flushed to the database - def counter_attribute_after_flush(&callback) - after_flush_callbacks << callback - end - - def counter_attribute_enabled?(attribute) - counter_attributes.include?(attribute) + # perform registered callbacks after increments have been committed to the database + def counter_attribute_after_commit(&callback) + after_commit_callbacks << callback end end - # This method must only be called by FlushCounterIncrementsWorker - # because it should run asynchronously and with exclusive lease. - # This will - # 1. temporarily move the pending increment for a given attribute - # to a relative "flushed" Redis key, delete the increment key and return - # the value. If new increments are performed at this point, the increment - # key is recreated as part of `delayed_increment_counter`. - # The "flushed" key is used to ensure that we can keep incrementing - # counters in Redis while flushing existing values. - # 2. then the value is used to update the counter in the database. - # 3. finally the "flushed" key is deleted. - def flush_increments_to_database!(attribute) - lock_key = counter_lock_key(attribute) - - with_exclusive_lease(lock_key) do - previous_db_value = read_attribute(attribute) - increment_key = counter_key(attribute) - flushed_key = counter_flushed_key(attribute) - increment_value = steal_increments(increment_key, flushed_key) - new_db_value = nil - - next if increment_value == 0 - - transaction do - update_counters_with_lease({ attribute => increment_value }) - redis_state { |redis| redis.del(flushed_key) } - new_db_value = reset.read_attribute(attribute) - end + def counter_attribute_enabled?(attribute) + counter_attribute = self.class.counter_attributes.find { |registered| registered[:attribute] == attribute } + return false unless counter_attribute + return true unless counter_attribute[:if_proc] - execute_after_flush_callbacks + counter_attribute[:if_proc].call(self) + end - log_flush_counter(attribute, increment_value, previous_db_value, new_db_value) + def counter(attribute) + strong_memoize_with(:counter, attribute) do + # This needs #to_sym because attribute could come from a Sidekiq param, + # which would be a string. + build_counter_for(attribute.to_sym) end end - def delayed_increment_counter(attribute, increment) - raise ArgumentError, "#{attribute} is not a counter attribute" unless counter_attribute_enabled?(attribute) - + def increment_counter(attribute, increment) return if increment == 0 run_after_commit_or_now do - increment_counter(attribute, increment) - - FlushCounterIncrementsWorker.perform_in(WORKER_DELAY, self.class.name, self.id, attribute) - end - - true - end - - def increment_counter(attribute, increment) - if counter_attribute_enabled?(attribute) - new_value = redis_state do |redis| - redis.incrby(counter_key(attribute), increment) - end + new_value = counter(attribute).increment(increment) log_increment_counter(attribute, increment, new_value) end @@ -137,74 +104,33 @@ module CounterAttribute end def reset_counter!(attribute) - if counter_attribute_enabled?(attribute) - detect_race_on_record(log_fields: { caller: __method__, attributes: attribute }) do - update!(attribute => 0) - clear_counter!(attribute) - end - - log_clear_counter(attribute) + detect_race_on_record(log_fields: { caller: __method__, attributes: attribute }) do + counter(attribute).reset! end - end - def get_counter_value(attribute) - if counter_attribute_enabled?(attribute) - redis_state do |redis| - redis.get(counter_key(attribute)).to_i - end - end + log_clear_counter(attribute) end - def counter_key(attribute) - "project:{#{project_id}}:counters:#{self.class}:#{id}:#{attribute}" - end - - def counter_flushed_key(attribute) - counter_key(attribute) + ':flushed' - end - - def counter_lock_key(attribute) - counter_key(attribute) + ':lock' - end - - def counter_attribute_enabled?(attribute) - self.class.counter_attribute_enabled?(attribute) + def execute_after_commit_callbacks + self.class.after_commit_callbacks.each do |callback| + callback.call(self.reset) + end end private - def database_lock_key - "project:{#{project_id}}:#{self.class}:#{id}" - end - - def steal_increments(increment_key, flushed_key) - redis_state do |redis| - redis.eval(LUA_STEAL_INCREMENT_SCRIPT, keys: [increment_key, flushed_key]) - end - end + def build_counter_for(attribute) + raise ArgumentError, %(attribute "#{attribute}" does not exist) unless has_attribute?(attribute) - def clear_counter!(attribute) - redis_state do |redis| - redis.del(counter_key(attribute)) - end - end - - def execute_after_flush_callbacks - self.class.after_flush_callbacks.each do |callback| - callback.call(self) + if counter_attribute_enabled?(attribute) + Gitlab::Counters::BufferedCounter.new(self, attribute) + else + Gitlab::Counters::LegacyCounter.new(self, attribute) end end - def redis_state(&block) - Gitlab::Redis::SharedState.with(&block) - end - - def with_exclusive_lease(lock_key) - in_lock(lock_key, ttl: WORKER_LOCK_TTL) do - yield - end - rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError - # a worker is already updating the counters + def database_lock_key + "project:{#{project_id}}:#{self.class}:#{id}" end # detect_race_on_record uses a lease to monitor access @@ -258,19 +184,6 @@ module CounterAttribute Gitlab::AppLogger.info(payload) end - def log_flush_counter(attribute, increment, previous_db_value, new_db_value) - payload = Gitlab::ApplicationContext.current.merge( - message: 'Flush counter attribute to database', - attribute: attribute, - project_id: project_id, - increment: increment, - previous_db_value: previous_db_value, - new_db_value: new_db_value - ) - - Gitlab::AppLogger.info(payload) - end - def log_clear_counter(attribute) payload = Gitlab::ApplicationContext.current.merge( message: 'Clear counter attribute', diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb index ad070090dd5..1af655277b8 100644 --- a/app/models/concerns/has_user_type.rb +++ b/app/models/concerns/has_user_type.rb @@ -13,10 +13,11 @@ module HasUserType project_bot: 6, migration_bot: 7, security_bot: 8, - automation_bot: 9 + automation_bot: 9, + admin_bot: 11 }.with_indifferent_access.freeze - BOT_USER_TYPES = %w[alert_bot project_bot support_bot visual_review_bot migration_bot security_bot automation_bot].freeze + BOT_USER_TYPES = %w[alert_bot project_bot support_bot visual_review_bot migration_bot security_bot automation_bot admin_bot].freeze NON_INTERNAL_USER_TYPES = %w[human project_bot service_user].freeze INTERNAL_USER_TYPES = (USER_TYPES.keys - NON_INTERNAL_USER_TYPES).freeze @@ -24,7 +25,6 @@ module HasUserType scope :humans, -> { where(user_type: :human) } scope :bots, -> { where(user_type: BOT_USER_TYPES) } scope :without_bots, -> { humans.or(where.not(user_type: BOT_USER_TYPES)) } - scope :bots_without_project_bot, -> { where(user_type: BOT_USER_TYPES - ['project_bot']) } scope :non_internal, -> { humans.or(where(user_type: NON_INTERNAL_USER_TYPES)) } scope :without_ghosts, -> { humans.or(where.not(user_type: :ghost)) } scope :without_project_bot, -> { humans.or(where.not(user_type: :project_bot)) } diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 31b2a8d7cc1..9f0cd96a8f8 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -366,7 +366,7 @@ module Issuable select(issuable_columns) .select(extra_select_columns) - .from("#{table_name}") + .from(table_name.to_s) .joins("JOIN LATERAL(#{highest_priority}) as highest_priorities ON TRUE") .group(group_columns) .reorder(highest_priority_arel_with_direction.nulls_last) diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb index a95bed7ad42..e95a8a42aa6 100644 --- a/app/models/concerns/milestoneable.rb +++ b/app/models/concerns/milestoneable.rb @@ -9,6 +9,12 @@ module Milestoneable extend ActiveSupport::Concern + class_methods do + def milestone_releases_subquery + Milestone.joins(:releases).where("#{table_name}.milestone_id = milestones.id") + end + end + included do belongs_to :milestone @@ -17,9 +23,15 @@ module Milestoneable scope :any_milestone, -> { where.not(milestone_id: nil) } scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) } scope :without_particular_milestones, ->(titles) { left_outer_joins(:milestone).where("milestones.title NOT IN (?) OR milestone_id IS NULL", titles) } - scope :any_release, -> { joins_milestone_releases } - scope :with_release, -> (tag, project_id) { joins_milestone_releases.where(milestones: { releases: { tag: tag, project_id: project_id } }) } - scope :without_particular_release, -> (tag, project_id) { joins_milestone_releases.where.not(milestones: { releases: { tag: tag, project_id: project_id } }) } + scope :any_release, -> do + where("EXISTS (?)", milestone_releases_subquery) + end + scope :with_release, -> (tag, project_id) do + where("EXISTS (?)", milestone_releases_subquery.where(releases: { tag: tag, project_id: project_id })) + end + scope :without_particular_release, -> (tag, project_id) do + where("EXISTS (?)", milestone_releases_subquery.where.not(releases: { tag: tag, project_id: project_id })) + end scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") } scope :order_milestone_due_desc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC')) } @@ -30,11 +42,6 @@ module Milestoneable .where(milestone_releases: { release_id: nil }) end - scope :joins_milestone_releases, -> do - joins("JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id - JOIN releases ON milestone_releases.release_id = releases.id").distinct - end - private def milestone_is_valid diff --git a/app/models/concerns/sensitive_serializable_hash.rb b/app/models/concerns/sensitive_serializable_hash.rb index 4ad8d16fcb9..794748483e4 100644 --- a/app/models/concerns/sensitive_serializable_hash.rb +++ b/app/models/concerns/sensitive_serializable_hash.rb @@ -19,8 +19,6 @@ module SensitiveSerializableHash # In general, prefer NOT to use serializable_hash / to_json / as_json in favor # of serializers / entities instead which has an allowlist of attributes def serializable_hash(options = nil) - return super if options && options[:unsafe_serialization_hash] - options = options.try(:dup) || {} options[:except] = Array(options[:except]).dup diff --git a/app/models/concerns/signature_type.rb b/app/models/concerns/signature_type.rb new file mode 100644 index 00000000000..804f42b6f72 --- /dev/null +++ b/app/models/concerns/signature_type.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module SignatureType + TYPES = %i[gpg ssh x509].freeze + + def type + raise NoMethodError, 'must implement `type` method' + end + + TYPES.each do |type| + define_method("#{type}?") { self.type == type } + end +end diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb index eccb004b503..6532a18d1b8 100644 --- a/app/models/concerns/sortable.rb +++ b/app/models/concerns/sortable.rb @@ -72,7 +72,7 @@ module Sortable private - def highest_label_priority(target_type_column: nil, target_type: nil, target_column:, project_column:, excluded_labels: []) + def highest_label_priority(target_column:, project_column:, target_type_column: nil, target_type: nil, excluded_labels: []) query = Label.select(LabelPriority.arel_table[:priority].minimum.as('label_priority')) .left_join_priorities .joins(:label_links) diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb index ee5774d4868..05addcf83d2 100644 --- a/app/models/concerns/taskable.rb +++ b/app/models/concerns/taskable.rb @@ -63,14 +63,15 @@ module Taskable def task_status(short: false) return '' if description.blank? - prep, completed = if short - ['/', ''] - else - [' of ', ' completed'] - end - sum = tasks.summary - "#{sum.complete_count}#{prep}#{sum.item_count} #{'checklist item'.pluralize(sum.item_count)}#{completed}" + checklist_item_noun = n_('checklist item', 'checklist items', sum.item_count) + if short + format(s_('Tasks|%{complete_count}/%{total_count} %{checklist_item_noun}'), +checklist_item_noun: checklist_item_noun, complete_count: sum.complete_count, total_count: sum.item_count) + else + format(s_('Tasks|%{complete_count} of %{total_count} %{checklist_item_noun} completed'), +checklist_item_noun: checklist_item_noun, complete_count: sum.complete_count, total_count: sum.item_count) + end end # Return a short string that describes the current state of this Taskable's diff --git a/app/models/concerns/time_trackable.rb b/app/models/concerns/time_trackable.rb index 54fe9eac2bc..2b7447dc700 100644 --- a/app/models/concerns/time_trackable.rb +++ b/app/models/concerns/time_trackable.rb @@ -15,12 +15,13 @@ module TimeTrackable alias_method :time_spent?, :time_spent - default_value_for :time_estimate, value: 0, allows_nil: false + attribute :time_estimate, default: 0 validates :time_estimate, numericality: { message: 'has an invalid format' }, allow_nil: false validate :check_negative_time_spent has_many :timelogs, dependent: :destroy, autosave: true # rubocop:disable Cop/ActiveRecordDependent + after_initialize :set_time_estimate_default_value end # rubocop:disable Gitlab/ModuleWithInstanceVariables @@ -67,6 +68,13 @@ module TimeTrackable val.is_a?(Integer) ? super([val, Gitlab::Database::MAX_INT_VALUE].min) : super(val) end + def set_time_estimate_default_value + return if new_record? + return unless has_attribute?(:time_estimate) + + self.time_estimate ||= self.class.column_defaults['time_estimate'] + end + private def reset_spent_time diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index 7da4e31b472..db0fcd915b3 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -98,6 +98,8 @@ class ContainerRepository < ApplicationRecord ) end + before_update :set_status_updated_at_to_now, if: :status_changed? + state_machine :migration_state, initial: :default, use_transactions: false do state :pre_importing do validates :migration_pre_import_started_at, presence: true @@ -521,11 +523,20 @@ class ContainerRepository < ApplicationRecord end def set_delete_ongoing_status - update_columns(status: :delete_ongoing, delete_started_at: Time.zone.now) + now = Time.zone.now + update_columns( + status: :delete_ongoing, + delete_started_at: now, + status_updated_at: now + ) end def set_delete_scheduled_status - update_columns(status: :delete_scheduled, delete_started_at: nil) + update_columns( + status: :delete_scheduled, + delete_started_at: nil, + status_updated_at: Time.zone.now + ) end def migration_in_active_state? @@ -623,6 +634,10 @@ class ContainerRepository < ApplicationRecord tag end end + + def set_status_updated_at_to_now + self.status_updated_at = Time.zone.now + end end ContainerRepository.prepend_mod_with('ContainerRepository') diff --git a/app/models/customer_relations/organization.rb b/app/models/customer_relations/organization.rb index 5eda9b4bf15..91656d4f846 100644 --- a/app/models/customer_relations/organization.rb +++ b/app/models/customer_relations/organization.rb @@ -85,8 +85,8 @@ class CustomerRelations::Organization < ApplicationRecord private def self.default_state_counts - states.keys.each_with_object({}) do |key, memo| - memo[key] = 0 + states.keys.index_with do |key| + 0 end end diff --git a/app/models/dependency_proxy/group_setting.rb b/app/models/dependency_proxy/group_setting.rb index 3a7ae66a263..b39ea36644a 100644 --- a/app/models/dependency_proxy/group_setting.rb +++ b/app/models/dependency_proxy/group_setting.rb @@ -3,7 +3,5 @@ class DependencyProxy::GroupSetting < ApplicationRecord belongs_to :group - attribute :enabled, default: true - validates :group, presence: true end diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb index 66d1ce01814..498ca9c4f30 100644 --- a/app/models/deploy_token.rb +++ b/app/models/deploy_token.rb @@ -37,6 +37,7 @@ class DeployToken < ApplicationRecord message: "can contain only letters, digits, '_', '-', '+', and '.'" } + validates :expires_at, iso8601_date: true, on: :create validates :deploy_token_type, presence: true enum deploy_token_type: { group_type: 1, diff --git a/app/models/deployment.rb b/app/models/deployment.rb index ea92b978d3a..1254ce1c90a 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -363,6 +363,10 @@ class Deployment < ApplicationRecord deployable&.user || user end + def triggered_by?(user) + deployed_by == user + end + def link_merge_requests(relation) # NOTE: relation.select will perform column deduplication, # when id == environment_id it will outputs 2 columns instead of 3 @@ -441,9 +445,10 @@ class Deployment < ApplicationRecord # default tag limit is 100, 0 means no limit # when refs_by_oid is passed an SHA, returns refs for that commit def tags(limit: 100) - project.repository.refs_by_oid(oid: sha, limit: limit, ref_patterns: [Gitlab::Git::TAG_REF_PREFIX]) || [] + strong_memoize_with(:tag, limit) do + project.repository.refs_by_oid(oid: sha, limit: limit, ref_patterns: [Gitlab::Git::TAG_REF_PREFIX]) || [] + end end - strong_memoize_attr :tags private diff --git a/app/models/environment.rb b/app/models/environment.rb index 2d3f342953f..f1edfb3a34b 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -6,6 +6,7 @@ class Environment < ApplicationRecord include FastDestroyAll::Helpers include Presentable include NullifyIfBlank + include FromUnion self.reactive_cache_refresh_interval = 1.minute self.reactive_cache_lifetime = 55.seconds @@ -27,27 +28,29 @@ class Environment < ApplicationRecord has_many :self_managed_prometheus_alert_events, inverse_of: :environment has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :environment - # NOTE: If you preload multiple last deployments of environments, use Preloaders::Environments::DeploymentPreloader. - has_one :last_deployment, -> { success.ordered }, class_name: 'Deployment', inverse_of: :environment - has_one :last_visible_deployment, -> { visible.order(id: :desc) }, inverse_of: :environment, class_name: 'Deployment' + # NOTE: + # 1) no-op arguments is to prevent accidental legacy preloading. See: https://gitlab.com/gitlab-org/gitlab/-/issues/369240 + # 2) If you preload multiple last deployments of environments, use Preloaders::Environments::DeploymentPreloader. + has_one :last_deployment, -> (_env) { success.ordered }, class_name: 'Deployment', inverse_of: :environment + has_one :last_visible_deployment, -> (_env) { visible.order(id: :desc) }, inverse_of: :environment, class_name: 'Deployment' + has_one :upcoming_deployment, -> (_env) { upcoming.order(id: :desc) }, class_name: 'Deployment', inverse_of: :environment Deployment::FINISHED_STATUSES.each do |status| - has_one :"last_#{status}_deployment", -> { where(status: status).ordered }, + has_one :"last_#{status}_deployment", -> (_env) { where(status: status).ordered }, class_name: 'Deployment', inverse_of: :environment end Deployment::UPCOMING_STATUSES.each do |status| - has_one :"last_#{status}_deployment", -> { where(status: status).ordered_as_upcoming }, + has_one :"last_#{status}_deployment", -> (_env) { where(status: status).ordered_as_upcoming }, class_name: 'Deployment', inverse_of: :environment end - has_one :upcoming_deployment, -> { upcoming.order(id: :desc) }, class_name: 'Deployment', inverse_of: :environment has_one :latest_opened_most_severe_alert, -> { order_severity_with_open_prometheus_alert }, class_name: 'AlertManagement::Alert', inverse_of: :environment before_validation :generate_slug, if: ->(env) { env.slug.blank? } + before_validation :ensure_environment_tier before_save :set_environment_type - before_save :ensure_environment_tier after_save :clear_reactive_cache! validates :name, @@ -68,6 +71,10 @@ class Environment < ApplicationRecord length: { maximum: 255 }, allow_nil: true + # Currently, the tier presence is validaed for newly created environments. + # After the `BackfillEnvironmentTiers` background migration has been completed, we should remove `on: :create`. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/385253. + validates :tier, presence: true, on: :create validate :safe_external_url validate :merge_request_not_changed @@ -87,7 +94,6 @@ class Environment < ApplicationRecord scope :in_review_folder, -> { where(environment_type: "review") } scope :for_name, -> (name) { where(name: name) } - scope :preload_cluster, -> { preload(last_deployment: :cluster) } scope :preload_project, -> { preload(:project) } scope :auto_stoppable, -> (limit) { available.where('auto_stop_at < ?', Time.zone.now).limit(limit) } scope :auto_deletable, -> (limit) { stopped.where('auto_delete_at < ?', Time.zone.now).limit(limit) } @@ -96,7 +102,16 @@ class Environment < ApplicationRecord # Search environments which have names like the given query. # Do not set a large limit unless you've confirmed that it works on gitlab.com scale. scope :for_name_like, -> (query, limit: 5) do - where('LOWER(environments.name) LIKE LOWER(?) || \'%\'', sanitize_sql_like(query)).limit(limit) + top_level = 'LOWER(environments.name) LIKE LOWER(?) || \'%\'' + + where(top_level, sanitize_sql_like(query)).limit(limit) + end + + scope :for_name_like_within_folder, -> (query, limit: 5) do + within_folder = 'LOWER(ltrim(environments.name, environments.environment_type'\ + ' || \'/\')) LIKE LOWER(?) || \'%\'' + + where(within_folder, sanitize_sql_like(query)).limit(limit) end scope :for_project, -> (project) { where(project_id: project) } @@ -106,7 +121,6 @@ class Environment < ApplicationRecord scope :with_rank, -> do select('environments.*, rank() OVER (PARTITION BY project_id ORDER BY id DESC)') end - scope :for_id, -> (id) { where(id: id) } scope :with_deployment, -> (sha, status: nil) do deployments = Deployment.select(1).where('deployments.environment_id = environments.id').where(sha: sha) @@ -197,12 +211,19 @@ class Environment < ApplicationRecord update_all(auto_delete_at: at_time) end + def self.nested + group('COALESCE(environment_type, id::text)', 'COALESCE(environment_type, name)') + .select('COALESCE(environment_type, id::text), COALESCE(environment_type, name) AS name', + 'COUNT(*) AS size', 'MAX(id) AS last_id') + .order('name ASC') + end + class << self def count_by_state environments_count_by_state = group(:state).count - valid_states.each_with_object({}) do |state, count_hash| - count_hash[state] = environments_count_by_state[state.to_s] || 0 + valid_states.index_with do |state| + environments_count_by_state[state.to_s] || 0 end end end @@ -490,6 +511,12 @@ class Environment < ApplicationRecord environment_type.nil? end + def deploy_freezes + Gitlab::SafeRequestStore.fetch("project:#{project_id}:freeze_periods_for_environments") do + project.freeze_periods + end + end + private # We deliberately avoid using AddressableUrlValidator to allow users to update their environments even if they have diff --git a/app/models/event.rb b/app/models/event.rb index a1417db3410..ed65b367b8a 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -132,7 +132,7 @@ class Event < ApplicationRecord where( 'action IN (?) OR (target_type IN (?) AND action IN (?))', [actions[:pushed], actions[:commented]], - %w(MergeRequest Issue), [actions[:created], actions[:closed], actions[:merged]] + %w(MergeRequest Issue WorkItem), [actions[:created], actions[:closed], actions[:merged]] ) end @@ -380,13 +380,11 @@ class Event < ApplicationRecord protected def capability - @capability ||= begin - capabilities.flat_map do |ability, syms| - if syms.any? { |sym| send(sym) } # rubocop: disable GitlabSecurity/PublicSend - [ability] - else - [] - end + @capability ||= capabilities.flat_map do |ability, syms| + if syms.any? { |sym| send(sym) } # rubocop: disable GitlabSecurity/PublicSend + [ability] + else + [] end end end diff --git a/app/models/generic_commit_status.rb b/app/models/generic_commit_status.rb index 6c8bfc35334..b02074849a1 100644 --- a/app/models/generic_commit_status.rb +++ b/app/models/generic_commit_status.rb @@ -3,8 +3,6 @@ class GenericCommitStatus < CommitStatus EXTERNAL_STAGE_IDX = 1_000_000 - before_validation :set_default_values - validates :target_url, addressable_url: true, length: { maximum: 255 }, allow_nil: true @@ -13,12 +11,6 @@ class GenericCommitStatus < CommitStatus # GitHub compatible API alias_attribute :context, :name - def set_default_values - self.context ||= 'default' - self.stage ||= 'external' - self.stage_idx ||= EXTERNAL_STAGE_IDX - end - def tags [:external] end diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 2db074e733e..1bf35179393 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -40,8 +40,8 @@ class GpgKey < ApplicationRecord unless: -> { errors.has_key?(:key) } before_validation :extract_fingerprint, :extract_primary_keyid - after_commit :update_invalid_gpg_signatures, on: :create after_create :generate_subkeys + after_commit :update_invalid_gpg_signatures, on: :create def primary_keyid super&.upcase diff --git a/app/models/group.rb b/app/models/group.rb index 098116ed800..0cdd7dd8596 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -20,6 +20,7 @@ class Group < Namespace include BulkUsersByEmailLoad include ChronicDurationAttribute include RunnerTokenExpirationInterval + include Todoable extend ::Gitlab::Utils::Override @@ -119,7 +120,7 @@ class Group < Namespace has_many :group_callouts, class_name: 'Users::GroupCallout', foreign_key: :group_id - has_many :protected_branches, inverse_of: :group + has_many :protected_branches, inverse_of: :group, foreign_key: :namespace_id has_one :group_feature, inverse_of: :group, class_name: 'Groups::FeatureSetting' @@ -154,10 +155,10 @@ class Group < Namespace prefix: RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX after_create :post_create_hook + after_create -> { create_or_load_association(:group_feature) } + after_update :path_changed_hook, if: :saved_change_to_path? after_destroy :post_destroy_hook after_commit :update_two_factor_requirement - after_update :path_changed_hook, if: :saved_change_to_path? - after_create -> { create_or_load_association(:group_feature) } scope :with_users, -> { includes(:users) } @@ -165,7 +166,16 @@ class Group < Namespace scope :by_id, ->(groups) { where(id: groups) } - scope :by_ids_or_paths, -> (ids, paths) { by_id(ids).or(where(path: paths)) } + scope :by_ids_or_paths, -> (ids, paths) do + return by_id(ids) unless paths.present? + + ids_by_full_path = Route + .for_routable_type(Namespace.name) + .where('LOWER(routes.path) IN (?)', paths.map(&:downcase)) + .select(:namespace_id) + + Group.from_union([by_id(ids), by_id(ids_by_full_path), where('LOWER(path) IN (?)', paths.map(&:downcase))]) + end scope :for_authorized_group_members, -> (user_ids) do joins(:group_members) @@ -550,6 +560,11 @@ class Group < Namespace members_with_parents.pluck(Arel.sql('DISTINCT members.user_id')) end + def self_and_hierarchy_intersecting_with_user_groups(user) + user_groups = GroupsFinder.new(user).execute.unscope(:order) + self_and_hierarchy.unscope(:order).where(id: user_groups) + end + def self_and_ancestors_ids strong_memoize(:self_and_ancestors_ids) do self_and_ancestors.pluck(:id) @@ -831,6 +846,7 @@ class Group < Namespace def has_project_with_service_desk_enabled? Gitlab::ServiceDesk.supported? && all_projects.service_desk_enabled.exists? end + strong_memoize_attr :has_project_with_service_desk_enabled?, :has_project_with_service_desk_enabled def activity_path Gitlab::Routing.url_helpers.activity_group_path(self) @@ -887,6 +903,10 @@ class Group < Namespace feature_flag_enabled_for_self_or_ancestor?(:work_items) end + def work_items_mvc_feature_flag_enabled? + feature_flag_enabled_for_self_or_ancestor?(:work_items_mvc) + end + def work_items_mvc_2_feature_flag_enabled? feature_flag_enabled_for_self_or_ancestor?(:work_items_mvc_2) end diff --git a/app/models/group_deploy_key.rb b/app/models/group_deploy_key.rb index c65b00a6de0..9495df7ab6d 100644 --- a/app/models/group_deploy_key.rb +++ b/app/models/group_deploy_key.rb @@ -12,6 +12,11 @@ class GroupDeployKey < Key joins(:group_deploy_keys_groups).where(group_deploy_keys_groups: { group_id: group_ids }).uniq end + # Remove usage_type because it defined in Key class but doesn't have a column in group_deploy_keys table + def self.defined_enums + super.without('usage_type') + end + def type 'DeployKey' end diff --git a/app/models/hooks/active_hook_filter.rb b/app/models/hooks/active_hook_filter.rb index cdcfd3f3ff5..4599ebf8717 100644 --- a/app/models/hooks/active_hook_filter.rb +++ b/app/models/hooks/active_hook_filter.rb @@ -18,10 +18,6 @@ class ActiveHookFilter branch_name = Gitlab::Git.branch_name(data[:ref]) - if Feature.disabled?(:enhanced_webhook_support_regex) - return RefMatcher.new(@hook.push_events_branch_filter).matches?(branch_name) - end - case @hook.branch_filter_strategy when 'all_branches' true diff --git a/app/models/hooks/service_hook.rb b/app/models/hooks/service_hook.rb index 27119d3a95a..94ced96bbde 100644 --- a/app/models/hooks/service_hook.rb +++ b/app/models/hooks/service_hook.rb @@ -13,4 +13,9 @@ class ServiceHook < WebHook override :parent delegate :parent, to: :integration + + override :executable? + def executable? + true + end end diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 946cdda2e75..189291a38ec 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -41,12 +41,9 @@ class WebHook < ApplicationRecord after_initialize :initialize_url_variables before_validation :reset_token - before_validation :set_branch_filter_nil, \ - if: -> { branch_filter_strategy_all_branches? && enhanced_webhook_support_regex? } - validates :push_events_branch_filter, \ - untrusted_regexp: true, if: -> { branch_filter_strategy_regex? && enhanced_webhook_support_regex? } - validates :push_events_branch_filter, \ - "web_hooks/wildcard_branch_filter": true, if: -> { branch_filter_strategy_wildcard? } + before_validation :set_branch_filter_nil, if: :branch_filter_strategy_all_branches? + validates :push_events_branch_filter, untrusted_regexp: true, if: :branch_filter_strategy_regex? + validates :push_events_branch_filter, "web_hooks/wildcard_branch_filter": true, if: :branch_filter_strategy_wildcard? validates :url_variables, json_schema: { filename: 'web_hooks_url_variables' } validate :no_missing_url_variables @@ -59,8 +56,6 @@ class WebHook < ApplicationRecord }, _prefix: true scope :executable, -> do - next all unless Feature.enabled?(:web_hooks_disable_failed) - where('recent_failures <= ? AND (disabled_until IS NULL OR disabled_until < ?)', FAILURE_THRESHOLD, Time.current) end @@ -69,23 +64,17 @@ class WebHook < ApplicationRecord where('recent_failures > ? OR disabled_until >= ?', FAILURE_THRESHOLD, Time.current) end - def self.web_hooks_disable_failed?(hook) - Feature.enabled?(:web_hooks_disable_failed, hook.parent) - end - def executable? !temporarily_disabled? && !permanently_disabled? end def temporarily_disabled? - return false unless web_hooks_disable_failed? return false if recent_failures <= FAILURE_THRESHOLD disabled_until.present? && disabled_until >= Time.current end def permanently_disabled? - return false unless web_hooks_disable_failed? return false if disabled_until.present? recent_failures > FAILURE_THRESHOLD @@ -197,7 +186,7 @@ class WebHook < ApplicationRecord end # See app/validators/json_schemas/web_hooks_url_variables.json - VARIABLE_REFERENCE_RE = /\{([A-Za-z_][A-Za-z0-9_]+)\}/.freeze + VARIABLE_REFERENCE_RE = /\{([A-Za-z]+[0-9]*(?:[._-][A-Za-z0-9]+)*)\}/.freeze def interpolated_url return url unless url.include?('{') @@ -232,10 +221,6 @@ class WebHook < ApplicationRecord backoff_count.succ.clamp(1, MAX_FAILURES) end - def web_hooks_disable_failed? - self.class.web_hooks_disable_failed?(self) - end - def initialize_url_variables self.url_variables = {} if encrypted_url_variables.nil? end @@ -257,10 +242,6 @@ class WebHook < ApplicationRecord errors.add(:url, "Invalid URL template. Missing keys: #{missing}") end - def enhanced_webhook_support_regex? - Feature.enabled?(:enhanced_webhook_support_regex) - end - def set_branch_filter_nil self.push_events_branch_filter = nil end diff --git a/app/models/import_export_upload.rb b/app/models/import_export_upload.rb index bc363cce8dd..bdb53653637 100644 --- a/app/models/import_export_upload.rb +++ b/app/models/import_export_upload.rb @@ -2,7 +2,6 @@ class ImportExportUpload < ApplicationRecord include WithUploads - include ObjectStorage::BackgroundMove belongs_to :project belongs_to :group diff --git a/app/models/integration.rb b/app/models/integration.rb index 41278dce22d..a630a6dee11 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -19,7 +19,7 @@ class Integration < ApplicationRecord INTEGRATION_NAMES = %w[ asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker datadog discord - drone_ci emails_on_push ewm external_wiki flowdock hangouts_chat harbor irker jira + drone_ci emails_on_push ewm external_wiki hangouts_chat harbor irker jira mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email pivotaltracker prometheus pumble pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack zentao ].freeze @@ -41,7 +41,9 @@ class Integration < ApplicationRecord Integrations::BaseCi Integrations::BaseIssueTracker Integrations::BaseMonitoring + Integrations::BaseSlackNotification Integrations::BaseSlashCommands + Integrations::BaseThirdPartyWiki ].freeze SECTION_TYPE_CONFIGURATION = 'configuration' diff --git a/app/models/integrations/asana.rb b/app/models/integrations/asana.rb index 2cfd71c9eb2..b8cfd718007 100644 --- a/app/models/integrations/asana.rb +++ b/app/models/integrations/asana.rb @@ -42,10 +42,8 @@ module Integrations end def client - @_client ||= begin - ::Asana::Client.new do |c| - c.authentication :access_token, api_key - end + @_client ||= ::Asana::Client.new do |c| + c.authentication :access_token, api_key end end diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb index b4e97f0871e..fc5e6a88c2d 100644 --- a/app/models/integrations/bamboo.rb +++ b/app/models/integrations/bamboo.rb @@ -16,7 +16,7 @@ module Integrations help: -> { s_('BambooService|Bamboo build plan key.') }, non_empty_password_title: -> { s_('BambooService|Enter new build key') }, non_empty_password_help: -> { s_('BambooService|Leave blank to use your current build key.') }, - placeholder: -> { s_('KEY') }, + placeholder: -> { _('KEY') }, required: true field :username, diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb index 750aa60b185..f2a707c2214 100644 --- a/app/models/integrations/base_chat_notification.rb +++ b/app/models/integrations/base_chat_notification.rb @@ -33,7 +33,10 @@ module Integrations boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch - validates :webhook, presence: true, public_url: true, if: :activated? + validates :webhook, + presence: true, + public_url: true, + if: -> (integration) { integration.activated? && integration.requires_webhook? } validates :labels_to_be_notified_behavior, inclusion: { in: LABEL_NOTIFICATION_BEHAVIOURS }, allow_blank: true def initialize_properties @@ -73,8 +76,6 @@ module Integrations def default_fields [ - { type: 'text', name: 'webhook', help: "#{webhook_help}", required: true }.freeze, - { type: 'text', name: 'username', placeholder: 'GitLab-integration' }.freeze, { type: 'checkbox', name: 'notify_only_broken_pipelines', help: 'Do not send notifications for successful pipelines.' }.freeze, { type: 'select', @@ -96,19 +97,24 @@ module Integrations ['Match all of the labels', MATCH_ALL_LABELS] ] }.freeze - ].freeze + ].tap do |fields| + next unless requires_webhook? + + fields.unshift( + { type: 'text', name: 'webhook', help: webhook_help, required: true }.freeze, + { type: 'text', name: 'username', placeholder: 'GitLab-integration' }.freeze + ) + end.freeze end def execute(data) - return unless supported_events.include?(data[:object_kind]) - - return unless webhook.present? - object_kind = data[:object_kind] + return false unless should_execute?(object_kind) + data = custom_data(data) - return unless notify_label?(data) + return false unless notify_label?(data) # WebHook events often have an 'update' event that follows a 'open' or # 'close' action. Ignore update events for now to prevent duplicate @@ -168,8 +174,17 @@ module Integrations self.public_send(field_name) # rubocop:disable GitlabSecurity/PublicSend end + def requires_webhook? + true + end + private + def should_execute?(object_kind) + supported_events.include?(object_kind) && + (!requires_webhook? || webhook.present?) + end + def log_usage(_, _) # Implement in child class end diff --git a/app/models/integrations/base_slack_notification.rb b/app/models/integrations/base_slack_notification.rb index cb785afdcfe..7a2a91aa0d2 100644 --- a/app/models/integrations/base_slack_notification.rb +++ b/app/models/integrations/base_slack_notification.rb @@ -32,13 +32,15 @@ module Integrations true end + private + override :log_usage def log_usage(event, user_id) return unless user_id return unless SUPPORTED_EVENTS_FOR_USAGE_LOG.include?(event) - key = "i_ecosystem_slack_service_#{event}_notification" + key = "#{metrics_key_prefix}_#{event}_notification" Gitlab::UsageDataCounters::HLLRedisCounter.track_event(key, values: user_id) @@ -55,8 +57,13 @@ module Integrations label: Integration::SNOWPLOW_EVENT_LABEL, property: key, user: User.find(user_id), + context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: key).to_context], **optional_arguments ) end + + def metrics_key_prefix + raise NotImplementedError + end end end diff --git a/app/models/integrations/base_slash_commands.rb b/app/models/integrations/base_slash_commands.rb index 314f0a6ee5d..11ff7547325 100644 --- a/app/models/integrations/base_slash_commands.rb +++ b/app/models/integrations/base_slash_commands.rb @@ -60,7 +60,7 @@ module Integrations # rubocop: disable CodeReuse/ServiceClass def find_chat_user(params) - ChatNames::FindUserService.new(self, params).execute + ChatNames::FindUserService.new(params[:team_id], params[:user_id]).execute end # rubocop: enable CodeReuse/ServiceClass diff --git a/app/models/integrations/confluence.rb b/app/models/integrations/confluence.rb index c1c43af99bf..31e9a171d1b 100644 --- a/app/models/integrations/confluence.rb +++ b/app/models/integrations/confluence.rb @@ -10,7 +10,7 @@ module Integrations validate :validate_confluence_url_is_cloud, if: :activated? field :confluence_url, - title: -> { s_('Confluence Cloud Workspace URL') }, + title: -> { _('Confluence Cloud Workspace URL') }, placeholder: 'https://example.atlassian.net/wiki', required: true diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb index 27bed5d3f76..80eecc14d0f 100644 --- a/app/models/integrations/datadog.rb +++ b/app/models/integrations/datadog.rb @@ -9,7 +9,7 @@ module Integrations URL_API_KEYS_DOCS = "https://docs.#{DEFAULT_DOMAIN}/account_management/api-app-keys/" SUPPORTED_EVENTS = %w[ - pipeline job archive_trace + pipeline build archive_trace ].freeze TAG_KEY_VALUE_RE = %r{\A [\w-]+ : .*\S.* \z}x.freeze @@ -48,8 +48,8 @@ module Integrations field :archive_trace_events, storage: :attribute, type: 'checkbox', - title: -> { s_('Logs') }, - checkbox_label: -> { s_('Enable logs collection') }, + title: -> { _('Logs') }, + checkbox_label: -> { _('Enable logs collection') }, help: -> { s_('When enabled, job logs are collected by Datadog and displayed along with pipeline execution traces.') } field :datadog_service, @@ -156,10 +156,10 @@ module Integrations end def execute(data) + return unless supported_events.include?(data[:object_kind]) + object_kind = data[:object_kind] object_kind = 'job' if object_kind == 'build' - return unless supported_events.include?(object_kind) - data = hook_data(data, object_kind) execute_web_hook!(data, "#{object_kind} hook") end diff --git a/app/models/integrations/flowdock.rb b/app/models/integrations/flowdock.rb index 52efb29f2c1..d7625cfb3d2 100644 --- a/app/models/integrations/flowdock.rb +++ b/app/models/integrations/flowdock.rb @@ -1,28 +1,12 @@ # frozen_string_literal: true +# This integration is scheduled for removal. +# All records must be deleted before the class can be removed. +# https://gitlab.com/gitlab-org/gitlab/-/issues/379197 module Integrations class Flowdock < Integration - validates :token, presence: true, if: :activated? - - field :token, - type: 'password', - help: -> { s_('FlowdockService|Enter your Flowdock token.') }, - non_empty_password_title: -> { s_('ProjectService|Enter new token') }, - non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, - placeholder: '1b609b52537...', - required: true - - def title - 'Flowdock' - end - - def description - s_('FlowdockService|Send event notifications from GitLab to Flowdock flows.') - end - - def help - docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/services', anchor: 'flowdock'), target: '_blank', rel: 'noopener noreferrer' - s_('FlowdockService|Send event notifications from GitLab to Flowdock flows. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + def readonly? + true end def self.to_param @@ -30,22 +14,7 @@ module Integrations end def self.supported_events - %w(push) - end - - def execute(data) - return unless supported_events.include?(data[:object_kind]) - - ::Flowdock::Git.post( - data[:ref], - data[:before], - data[:after], - token: token, - repo: project.repository, - repo_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}", - commit_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/-/commit/%s", - diff_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/compare/%s...%s" - ) + %w[] end end end diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb index 65492bfd9c2..45302a0bd09 100644 --- a/app/models/integrations/jira.rb +++ b/app/models/integrations/jira.rb @@ -132,11 +132,9 @@ module Integrations end def client - @client ||= begin - JIRA::Client.new(options).tap do |client| - # Replaces JIRA default http client with our implementation - client.request_client = Gitlab::Jira::HttpClient.new(client.options) - end + @client ||= JIRA::Client.new(options).tap do |client| + # Replaces JIRA default http client with our implementation + client.request_client = Gitlab::Jira::HttpClient.new(client.options) end end @@ -406,6 +404,7 @@ module Integrations label: Integration::SNOWPLOW_EVENT_LABEL, property: key, user: user, + context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: key).to_context], **optional_arguments ) end diff --git a/app/models/integrations/mattermost.rb b/app/models/integrations/mattermost.rb index dd1c98ee06b..e3c5c22ad3a 100644 --- a/app/models/integrations/mattermost.rb +++ b/app/models/integrations/mattermost.rb @@ -5,7 +5,7 @@ module Integrations include SlackMattermostNotifier def title - s_('Mattermost notifications') + _('Mattermost notifications') end def description diff --git a/app/models/integrations/packagist.rb b/app/models/integrations/packagist.rb index 7148de66aee..3973b492b6d 100644 --- a/app/models/integrations/packagist.rb +++ b/app/models/integrations/packagist.rb @@ -5,15 +5,15 @@ module Integrations include HasWebHook field :username, - title: -> { s_('Username') }, - help: -> { s_('Enter your Packagist username.') }, + title: -> { _('Username') }, + help: -> { _('Enter your Packagist username.') }, placeholder: '', required: true field :token, type: 'password', - title: -> { s_('Token') }, - help: -> { s_('Enter your Packagist token.') }, + title: -> { _('Token') }, + help: -> { _('Enter your Packagist token.') }, non_empty_password_title: -> { s_('ProjectService|Enter new token') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, placeholder: '', diff --git a/app/models/integrations/pushover.rb b/app/models/integrations/pushover.rb index 791e27c5db7..6bb6b6d60f6 100644 --- a/app/models/integrations/pushover.rb +++ b/app/models/integrations/pushover.rb @@ -112,7 +112,7 @@ module Integrations user: user_key, device: device, priority: priority, - title: "#{project.full_name}", + title: project.full_name.to_s, message: message, url: data[:project][:web_url], url_title: s_("PushoverService|See project %{project_full_name}") % { project_full_name: project.full_name } diff --git a/app/models/integrations/slack.rb b/app/models/integrations/slack.rb index 89326b8174f..07d2d802915 100644 --- a/app/models/integrations/slack.rb +++ b/app/models/integrations/slack.rb @@ -20,5 +20,12 @@ module Integrations def webhook_help 'https://hooks.slack.com/services/…' end + + private + + override :metrics_key_prefix + def metrics_key_prefix + 'i_ecosystem_slack_service' + end end end diff --git a/app/models/issue.rb b/app/models/issue.rb index fc083002c41..1dd11ff8315 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -91,7 +91,7 @@ class Issue < ApplicationRecord has_one :incident_management_issuable_escalation_status, class_name: 'IncidentManagement::IssuableEscalationStatus' has_and_belongs_to_many :self_managed_prometheus_alert_events, join_table: :issues_self_managed_prometheus_alert_events # rubocop: disable Rails/HasAndBelongsToMany has_and_belongs_to_many :prometheus_alert_events, join_table: :issues_prometheus_alert_events # rubocop: disable Rails/HasAndBelongsToMany - has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :issue + has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :issue, validate: false has_many :prometheus_alerts, through: :prometheus_alert_events has_many :issue_customer_relations_contacts, class_name: 'CustomerRelations::IssueContact', inverse_of: :issue has_many :customer_relations_contacts, through: :issue_customer_relations_contacts, source: :contact, class_name: 'CustomerRelations::Contact', inverse_of: :issues @@ -105,9 +105,10 @@ class Issue < ApplicationRecord validates :project, presence: true validates :issue_type, presence: true - validates :namespace, presence: true, if: -> { project.present? } + validates :namespace, presence: true validates :work_item_type, presence: true + validate :allowed_work_item_type_change, on: :update, if: :work_item_type_id_changed? validate :due_date_after_start_date validate :parent_link_confidentiality @@ -180,7 +181,7 @@ class Issue < ApplicationRecord scope :without_hidden, -> { if Feature.enabled?(:ban_user_feature_flag) - where.not(author_id: Users::BannedUser.all.select(:user_id)) + where('NOT EXISTS (?)', Users::BannedUser.select(1).where('issues.author_id = banned_users.user_id')) else all end @@ -216,8 +217,8 @@ class Issue < ApplicationRecord before_validation :ensure_namespace_id, :ensure_work_item_type - after_commit :expire_etag_cache, unless: :importing? after_save :ensure_metrics, unless: :importing? + after_commit :expire_etag_cache, unless: :importing? after_create_commit :record_create_action, unless: :importing? attr_spammable :title, spam_title: true @@ -743,6 +744,17 @@ class Issue < ApplicationRecord self.work_item_type = WorkItems::Type.default_by_type(issue_type) end + + def allowed_work_item_type_change + return unless changes[:work_item_type_id] + + involved_types = WorkItems::Type.where(id: changes[:work_item_type_id].compact).pluck(:base_type).uniq + disallowed_types = involved_types - WorkItems::Type::CHANGEABLE_BASE_TYPES + + return if disallowed_types.empty? + + errors.add(:work_item_type_id, format(_('can not be changed to %{new_type}'), new_type: work_item_type&.name)) + end end Issue.prepend_mod_with('Issue') diff --git a/app/models/issue_collection.rb b/app/models/issue_collection.rb deleted file mode 100644 index 05607fc3a08..00000000000 --- a/app/models/issue_collection.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -# IssueCollection can be used to reduce a list of issues down to a subset. -# -# IssueCollection is not meant to be some sort of Enumerable, instead it's meant -# to take a list of issues and return a new list of issues based on some -# criteria. For example, given a list of issues you may want to return a list of -# issues that can be read or updated by a given user. -class IssueCollection - attr_reader :collection - - def initialize(collection) - @collection = collection - end - - # Returns all the issues that can be updated by the user. - def updatable_by_user(user) - return collection if user.admin? - - # Given all the issue projects we get a list of projects that the current - # user has at least reporter access to. - projects_with_reporter_access = user - .projects_with_reporter_access_limited_to(project_ids) - .pluck(:id) - - collection.select do |issue| - if projects_with_reporter_access.include?(issue.project_id) - true - elsif issue.is_a?(Issue) - issue.assignee_or_author?(user) - else - false - end - end - end - - alias_method :visible_to, :updatable_by_user - - private - - def project_ids - @project_ids ||= collection.map(&:project_id).uniq - end -end diff --git a/app/models/issue_email_participant.rb b/app/models/issue_email_participant.rb index 76a96151350..dd963bc9e7e 100644 --- a/app/models/issue_email_participant.rb +++ b/app/models/issue_email_participant.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class IssueEmailParticipant < ApplicationRecord + include BulkInsertSafe + belongs_to :issue validates :email, uniqueness: { scope: [:issue_id], case_sensitive: false } diff --git a/app/models/iteration.rb b/app/models/iteration.rb index c6269313d8b..ebec24731ed 100644 --- a/app/models/iteration.rb +++ b/app/models/iteration.rb @@ -4,9 +4,6 @@ class Iteration < ApplicationRecord include IgnorableColumns - # TODO https://gitlab.com/gitlab-org/gitlab/-/issues/372126 - ignore_column :project_id, remove_with: '15.7', remove_after: '2022-11-18' - self.table_name = 'sprints' def self.reference_prefix diff --git a/app/models/jira_connect_installation.rb b/app/models/jira_connect_installation.rb index 23813fa138f..0e88d1ceae9 100644 --- a/app/models/jira_connect_installation.rb +++ b/app/models/jira_connect_installation.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class JiraConnectInstallation < ApplicationRecord + include Gitlab::Routing + attr_encrypted :shared_secret, mode: :per_attribute_iv, algorithm: 'aes-256-gcm', @@ -37,13 +39,19 @@ class JiraConnectInstallation < ApplicationRecord def audience_url return unless proxy? - Gitlab::Utils.append_path(instance_url, '/-/jira_connect') + Gitlab::Utils.append_path(instance_url, jira_connect_base_path) end def audience_installed_event_url return unless proxy? - Gitlab::Utils.append_path(instance_url, '/-/jira_connect/events/installed') + Gitlab::Utils.append_path(instance_url, jira_connect_events_installed_path) + end + + def audience_uninstalled_event_url + return unless proxy? + + Gitlab::Utils.append_path(instance_url, jira_connect_events_uninstalled_path) end def proxy? diff --git a/app/models/key.rb b/app/models/key.rb index 78b0a38bcaa..1f2234129ed 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -32,12 +32,18 @@ class Key < ApplicationRecord delegate :name, :email, to: :user, prefix: true - after_commit :add_to_authorized_keys, on: :create + enum usage_type: { + auth_and_signing: 0, + auth: 1, + signing: 2 + } + after_create :post_create_hook after_create :refresh_user_cache - after_commit :remove_from_authorized_keys, on: :destroy after_destroy :post_destroy_hook after_destroy :refresh_user_cache + after_commit :add_to_authorized_keys, on: :create + after_commit :remove_from_authorized_keys, on: :destroy alias_attribute :fingerprint_md5, :fingerprint alias_attribute :name, :title @@ -45,6 +51,8 @@ class Key < ApplicationRecord scope :preload_users, -> { preload(:user) } scope :for_user, -> (user) { where(user: user) } scope :order_last_used_at_desc, -> { reorder(arel_table[:last_used_at].desc.nulls_last) } + scope :auth, -> { where(usage_type: [:auth, :auth_and_signing]) } + scope :signing, -> { where(usage_type: [:signing, :auth_and_signing]) } # Date is set specifically in this scope to improve query time. scope :expired_today_and_not_notified, -> { where(["date(expires_at AT TIME ZONE 'UTC') = CURRENT_DATE AND expiry_notification_delivered_at IS NULL"]) } diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb index 8aa48561e60..e1f28c0e117 100644 --- a/app/models/lfs_object.rb +++ b/app/models/lfs_object.rb @@ -4,7 +4,6 @@ class LfsObject < ApplicationRecord include AfterCommitQueue include Checksummable include EachBatch - include ObjectStorage::BackgroundMove include FileStoreMounter has_many :lfs_objects_projects diff --git a/app/models/member.rb b/app/models/member.rb index 80c5fd7e468..107530daf51 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -61,6 +61,7 @@ class Member < ApplicationRecord validate :access_level_inclusion validate :validate_member_role_access_level validate :validate_access_level_locked_for_member_role, on: :update + validate :validate_member_role_belongs_to_same_root_namespace scope :with_invited_user_state, -> do joins('LEFT JOIN users as invited_user ON invited_user.email = members.invite_email') @@ -515,12 +516,22 @@ class Member < ApplicationRecord end end + def validate_member_role_belongs_to_same_root_namespace + return unless member_role_id + + return if member_namespace.id == member_role.namespace_id + return if member_namespace.root_ancestor.id == member_role.namespace_id + + errors.add(:member_namespace, _("must be in same hierarchy as custom role's namespace")) + end + def send_invite # override in subclass end def send_request notification_service.new_access_request(self) + todo_service.create_member_access_request(self) if source_type != 'Project' end def post_create_hook @@ -579,6 +590,12 @@ class Member < ApplicationRecord end # rubocop: enable CodeReuse/ServiceClass + # rubocop: disable CodeReuse/ServiceClass + def todo_service + TodoService.new + end + # rubocop: enable CodeReuse/ServiceClass + def notifiable_options {} end diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index ad1ad1e74fe..796b05b7fff 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -55,6 +55,12 @@ class GroupMember < Member { group: group } end + def last_owner_of_the_group? + return false unless access_level == Gitlab::Access::OWNER + + group.member_last_owner?(self) || group.member_last_blocked_owner?(self) + end + private override :refresh_member_authorized_projects diff --git a/app/models/members/member_role.rb b/app/models/members/member_role.rb index b4e3d6874ef..e9d7b1d3f80 100644 --- a/app/models/members/member_role.rb +++ b/app/models/members/member_role.rb @@ -1,18 +1,30 @@ # frozen_string_literal: true class MemberRole < ApplicationRecord # rubocop:disable Gitlab/NamespacedClass + include IgnorableColumns + ignore_column :download_code, remove_with: '15.9', remove_after: '2023-01-22' + has_many :members belongs_to :namespace validates :namespace, presence: true validates :base_access_level, presence: true validate :belongs_to_top_level_namespace + validate :validate_namespace_locked, on: :update + + validates_associated :members private def belongs_to_top_level_namespace return if !namespace || namespace.root? - errors.add(:namespace, s_("must be top-level namespace")) + errors.add(:namespace, s_("MemberRole|must be top-level namespace")) + end + + def validate_namespace_locked + return unless namespace_id_changed? + + errors.add(:namespace, s_("MemberRole|can't be changed")) end end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 1099e0f48c0..6aa6afb595d 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -96,6 +96,10 @@ class ProjectMember < Member { project: project } end + def holder_of_the_personal_namespace? + project.personal_namespace_holder?(user) + end + private override :access_level_inclusion diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 735c0df1529..78c6d983a3d 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -121,6 +121,7 @@ class MergeRequest < ApplicationRecord has_many :draft_notes has_many :reviews, inverse_of: :merge_request + has_many :reviewed_by_users, -> { distinct }, through: :reviews, source: :author has_many :created_environments, class_name: 'Environment', foreign_key: :merge_request_id, inverse_of: :merge_request KNOWN_MERGE_PARAMS = [ @@ -139,6 +140,7 @@ class MergeRequest < ApplicationRecord after_create :ensure_merge_request_diff, unless: :skip_ensure_merge_request_diff after_update :clear_memoized_shas after_update :reload_diff_if_branch_changed + after_save :keep_around_commit, unless: :importing? after_commit :ensure_metrics, on: [:create, :update], unless: :importing? after_commit :expire_etag_cache, unless: :importing? @@ -246,7 +248,9 @@ class MergeRequest < ApplicationRecord end after_transition any => [:unchecked, :cannot_be_merged_recheck, :checking, :cannot_be_merged_rechecking, :can_be_merged, :cannot_be_merged] do |merge_request, transition| - GraphqlTriggers.merge_request_merge_status_updated(merge_request) + merge_request.run_after_commit do + GraphqlTriggers.merge_request_merge_status_updated(merge_request) + end end # rubocop: disable CodeReuse/ServiceClass @@ -438,8 +442,6 @@ class MergeRequest < ApplicationRecord .pick(MergeRequest::Metrics.time_to_merge_expression) end - after_save :keep_around_commit, unless: :importing? - alias_attribute :project, :target_project alias_attribute :project_id, :target_project_id @@ -1270,7 +1272,7 @@ class MergeRequest < ApplicationRecord end def mergeable_discussions_state? - return true unless project.only_allow_merge_if_all_discussions_are_resolved? + return true unless project.only_allow_merge_if_all_discussions_are_resolved?(inherit_group_setting: true) unresolved_notes.none?(&:to_be_resolved?) end @@ -1382,7 +1384,7 @@ class MergeRequest < ApplicationRecord def default_merge_commit_message(include_description: false, user: nil) if self.target_project.merge_commit_template.present? && !include_description - return ::Gitlab::MergeRequests::CommitMessageGenerator.new(merge_request: self, current_user: user).merge_message + return ::Gitlab::MergeRequests::MessageGenerator.new(merge_request: self, current_user: user).merge_commit_message end closes_issues_references = visible_closing_issues_for.map do |issue| @@ -1398,7 +1400,7 @@ class MergeRequest < ApplicationRecord message << "Closes #{closes_issues_references.to_sentence}" end - message << "#{description}" if include_description && description.present? + message << description if include_description && description.present? message << "See merge request #{to_reference(full: true)}" message.join("\n\n") @@ -1406,7 +1408,7 @@ class MergeRequest < ApplicationRecord def default_squash_commit_message(user: nil) if self.target_project.squash_commit_template.present? - return ::Gitlab::MergeRequests::CommitMessageGenerator.new(merge_request: self, current_user: user).squash_message + return ::Gitlab::MergeRequests::MessageGenerator.new(merge_request: self, current_user: user).squash_commit_message end title @@ -1451,9 +1453,9 @@ class MergeRequest < ApplicationRecord end def mergeable_ci_state? - return true unless project.only_allow_merge_if_pipeline_succeeds? + return true unless project.only_allow_merge_if_pipeline_succeeds?(inherit_group_setting: true) return false unless actual_head_pipeline - return true if project.allow_merge_on_skipped_pipeline? && actual_head_pipeline.skipped? + return true if project.allow_merge_on_skipped_pipeline?(inherit_group_setting: true) && actual_head_pipeline.skipped? actual_head_pipeline.success? end diff --git a/app/models/merge_request/predictions.rb b/app/models/merge_request/predictions.rb deleted file mode 100644 index ef9e00b5f74..00000000000 --- a/app/models/merge_request/predictions.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class MergeRequest::Predictions < ApplicationRecord # rubocop:disable Style/ClassAndModuleChildren - belongs_to :merge_request, inverse_of: :predictions - - validates :suggested_reviewers, json_schema: { filename: 'merge_request_predictions_suggested_reviewers' } -end diff --git a/app/models/merge_request_context_commit.rb b/app/models/merge_request_context_commit.rb index ebbdecf8aa7..281e11c7c13 100644 --- a/app/models/merge_request_context_commit.rb +++ b/app/models/merge_request_context_commit.rb @@ -12,7 +12,7 @@ class MergeRequestContextCommit < ApplicationRecord validates :sha, presence: true validates :sha, uniqueness: { message: 'has already been added' } - serialize :trailers, Serializers::Json # rubocop:disable Cop/ActiveRecordSerialize + attribute :trailers, :ind_jsonb validates :trailers, json_schema: { filename: 'git_trailers' } # Sort by committed date in descending order to ensure latest commits comes on the top diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 98a9ccc2040..cff8911d84b 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -6,7 +6,6 @@ class MergeRequestDiff < ApplicationRecord include ManualInverseAssociation include EachBatch include Gitlab::Utils::StrongMemoize - include ObjectStorage::BackgroundMove include BulkInsertableAssociations # Don't display more than 100 commits at once @@ -267,7 +266,7 @@ class MergeRequestDiff < ApplicationRecord end # This method will rely on repository branch sha - # in case start_commit_sha is nil. Its necesarry for old merge request diff + # in case start_commit_sha is nil. It's necessary for old merge request diff # created before version 8.4 to work def safe_start_commit_sha start_commit_sha || merge_request.target_branch_sha @@ -414,6 +413,29 @@ class MergeRequestDiff < ApplicationRecord end end + def paginated_diffs(page, per_page) + fetching_repository_diffs({}) do |comparison| + reorder_diff_files! + + collection = Gitlab::Diff::FileCollection::PaginatedMergeRequestDiff.new( + self, + page, + per_page + ) + + if comparison + comparison.diffs( + paths: collection.diff_paths, + page: collection.current_page, + per_page: collection.limit_value, + count: collection.total_count + ) + else + collection + end + end + end + def diffs(diff_options = nil) fetching_repository_diffs(diff_options) do |comparison| # It should fetch the repository when diffs are cleaned by the system. diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb index 152fb195c97..7e2efa2049b 100644 --- a/app/models/merge_request_diff_commit.rb +++ b/app/models/merge_request_diff_commit.rb @@ -35,7 +35,7 @@ class MergeRequestDiffCommit < ApplicationRecord sha_attribute :sha alias_attribute :id, :sha - serialize :trailers, Serializers::Json # rubocop:disable Cop/ActiveRecordSerialize + attribute :trailers, :ind_jsonb validates :trailers, json_schema: { filename: 'git_trailers' } scope :with_users, -> { preload(:commit_author, :committer) } diff --git a/app/models/ml/candidate.rb b/app/models/ml/candidate.rb index f7da4418624..f24161d598f 100644 --- a/app/models/ml/candidate.rb +++ b/app/models/ml/candidate.rb @@ -11,6 +11,7 @@ module Ml belongs_to :user has_many :metrics, class_name: 'Ml::CandidateMetric' has_many :params, class_name: 'Ml::CandidateParam' + has_many :metadata, class_name: 'Ml::CandidateMetadata' has_many :latest_metrics, -> { latest }, class_name: 'Ml::CandidateMetric', inverse_of: :candidate attribute :iid, default: -> { SecureRandom.uuid } @@ -18,7 +19,21 @@ module Ml scope :including_metrics_and_params, -> { includes(:latest_metrics, :params) } def artifact_root - "/ml_candidate_#{iid}/-/" + "/#{package_name}/#{package_version}/" + end + + def artifact + ::Packages::Generic::PackageFinder.new(experiment.project).execute!(package_name, package_version) + rescue ActiveRecord::RecordNotFound + nil + end + + def package_name + "ml_candidate_#{iid}" + end + + def package_version + '-' end class << self diff --git a/app/models/ml/candidate_metadata.rb b/app/models/ml/candidate_metadata.rb new file mode 100644 index 00000000000..06b893c211f --- /dev/null +++ b/app/models/ml/candidate_metadata.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Ml + class CandidateMetadata < ApplicationRecord + validates :candidate, presence: true + validates :name, + length: { maximum: 250 }, + presence: true, + uniqueness: { scope: :candidate, message: ->(candidate, _) { "'#{candidate.name}' already taken" } } + validates :value, length: { maximum: 5000 }, presence: true + + belongs_to :candidate, class_name: 'Ml::Candidate' + end +end diff --git a/app/models/ml/experiment.rb b/app/models/ml/experiment.rb index 05b238b960d..0a326b0e005 100644 --- a/app/models/ml/experiment.rb +++ b/app/models/ml/experiment.rb @@ -10,6 +10,7 @@ module Ml belongs_to :project belongs_to :user has_many :candidates, class_name: 'Ml::Candidate' + has_many :metadata, class_name: 'Ml::ExperimentMetadata' has_internal_id :iid, scope: :project diff --git a/app/models/ml/experiment_metadata.rb b/app/models/ml/experiment_metadata.rb new file mode 100644 index 00000000000..93496807e1a --- /dev/null +++ b/app/models/ml/experiment_metadata.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Ml + class ExperimentMetadata < ApplicationRecord + validates :experiment, presence: true + validates :name, + length: { maximum: 250 }, + presence: true, + uniqueness: { scope: :experiment, message: ->(exp, _) { "'#{exp.name}' already taken" } } + validates :value, length: { maximum: 5000 }, presence: true + + belongs_to :experiment, class_name: 'Ml::Experiment' + end +end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 51c39ad4ec3..d7d53956656 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -86,6 +86,7 @@ class Namespace < ApplicationRecord has_many :issues, inverse_of: :namespace has_many :timelog_categories, class_name: 'TimeTracking::TimelogCategory' + has_many :achievements, class_name: 'Achievements::Achievement' validates :owner, presence: true, if: ->(n) { n.owner_required? } validates :name, @@ -131,26 +132,28 @@ class Namespace < ApplicationRecord to: :namespace_settings, allow_nil: true delegate :show_diff_preview_in_email, :show_diff_preview_in_email?, :show_diff_preview_in_email=, to: :namespace_settings + delegate :runner_registration_enabled, :runner_registration_enabled?, :runner_registration_enabled=, + to: :namespace_settings delegate :maven_package_requests_forwarding, :pypi_package_requests_forwarding, :npm_package_requests_forwarding, to: :package_settings - after_save :reload_namespace_details - - after_commit :refresh_access_of_projects_invited_groups, on: :update, if: -> { previous_changes.key?('share_with_group_lock') } - before_create :sync_share_with_group_lock_with_parent before_update :sync_share_with_group_lock_with_parent, if: :parent_changed? after_update :force_share_with_group_lock_on_descendants, if: -> { saved_change_to_share_with_group_lock? && share_with_group_lock? } after_update :expire_first_auto_devops_config_cache, if: -> { saved_change_to_auto_devops_enabled? } + after_update :move_dir, if: :saved_change_to_path_or_parent?, unless: -> { is_a?(Namespaces::ProjectNamespace) } + after_destroy :rm_dir + after_save :reload_namespace_details + + after_commit :refresh_access_of_projects_invited_groups, on: :update, if: -> { previous_changes.key?('share_with_group_lock') } + after_sync_traversal_ids :schedule_sync_event_worker # custom callback defined in Namespaces::Traversal::Linear # Legacy Storage specific hooks - after_update :move_dir, if: :saved_change_to_path_or_parent?, unless: -> { is_a?(Namespaces::ProjectNamespace) } before_destroy(prepend: true) { prepare_for_destroy } - after_destroy :rm_dir after_commit :expire_child_caches, on: :update, if: -> { Feature.enabled?(:cached_route_lookups, self, type: :ops) && saved_change_to_name? || saved_change_to_path? || saved_change_to_parent_id? @@ -330,6 +333,13 @@ class Namespace < ApplicationRecord type.nil? || type == Namespaces::UserNamespace.sti_name || !(group_namespace? || project_namespace?) end + def bot_user_namespace? + return false unless user_namespace? + return false unless owner && owner.bot? + + true + end + def owner_required? user_namespace? end @@ -507,6 +517,10 @@ class Namespace < ApplicationRecord root? && actual_plan.paid? end + def prevent_delete? + paid? + end + def actual_limits # We default to PlanLimits.new otherwise a lot of specs would fail # On production each plan should already have associated limits record @@ -541,12 +555,10 @@ class Namespace < ApplicationRecord def shared_runners_setting if shared_runners_enabled SR_ENABLED + elsif allow_descendants_override_disabled_shared_runners + SR_DISABLED_WITH_OVERRIDE else - if allow_descendants_override_disabled_shared_runners - SR_DISABLED_WITH_OVERRIDE - else - SR_DISABLED_AND_UNOVERRIDABLE - end + SR_DISABLED_AND_UNOVERRIDABLE end end @@ -597,6 +609,10 @@ class Namespace < ApplicationRecord namespace_settings&.enabled_git_access_protocol end + def all_ancestors_have_runner_registration_enabled? + namespace_settings&.all_ancestors_have_runner_registration_enabled? + end + private def cluster_enabled_granted? diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb index 3e6371b0c4d..5081d5cdafe 100644 --- a/app/models/namespace_setting.rb +++ b/app/models/namespace_setting.rb @@ -59,6 +59,16 @@ class NamespaceSetting < ApplicationRecord all_ancestors_allow_diff_preview_in_email? end + def runner_registration_enabled? + runner_registration_enabled && all_ancestors_have_runner_registration_enabled? + end + + def all_ancestors_have_runner_registration_enabled? + return true unless namespace.has_parent? + + !self.class.where(namespace_id: namespace.ancestors, runner_registration_enabled: false).exists? + end + private def all_ancestors_allow_diff_preview_in_email? diff --git a/app/models/namespace_statistics.rb b/app/models/namespace_statistics.rb index 04ca05d85ff..a17ca2e2c1d 100644 --- a/app/models/namespace_statistics.rb +++ b/app/models/namespace_statistics.rb @@ -10,8 +10,8 @@ class NamespaceStatistics < ApplicationRecord # rubocop:disable Gitlab/Namespace scope :for_namespaces, -> (namespaces) { where(namespace: namespaces) } before_save :update_storage_size - after_save :update_root_storage_statistics, if: :saved_change_to_storage_size? after_destroy :update_root_storage_statistics + after_save :update_root_storage_statistics, if: :saved_change_to_storage_size? delegate :group_namespace?, to: :namespace diff --git a/app/models/note.rb b/app/models/note.rb index 8e1f4979602..052df6142c5 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -168,10 +168,10 @@ class Note < ApplicationRecord # Syncs `confidential` with `internal` as we rename the column. # https://gitlab.com/gitlab-org/gitlab/-/issues/367923 before_create :set_internal_flag + after_destroy :expire_etag_cache after_save :keep_around_commit, if: :for_project_noteable?, unless: -> { importing? || skip_keep_around_commits } after_save :expire_etag_cache, unless: :importing? after_save :touch_noteable, unless: :importing? - after_destroy :expire_etag_cache after_commit :notify_after_create, on: :create after_commit :notify_after_destroy, on: :destroy diff --git a/app/models/operations/feature_flags_client.rb b/app/models/operations/feature_flags_client.rb index e8c237abbc5..5a05d76254d 100644 --- a/app/models/operations/feature_flags_client.rb +++ b/app/models/operations/feature_flags_client.rb @@ -19,11 +19,11 @@ module Operations before_validation :ensure_token! - def self.find_for_project_and_token(project, token) - return unless project + def self.find_for_project_and_token(project_id, token) + return unless project_id return unless token - where(project_id: project).find_by_token(token) + where(project_id: project_id).find_by_token(token) end def self.update_last_feature_flag_updated_at!(project) diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index 317db51f4ef..17c5415939c 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -149,6 +149,7 @@ class Packages::Package < ApplicationRecord end scope :preload_composer, -> { preload(:composer_metadatum) } scope :preload_npm_metadatum, -> { preload(:npm_metadatum) } + scope :preload_pypi_metadatum, -> { preload(:pypi_metadatum) } scope :without_nuget_temporary_name, -> { where.not(name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) } diff --git a/app/models/packages/rpm/repository_file.rb b/app/models/packages/rpm/repository_file.rb index 4b5fa59c6ee..614ec9b3e56 100644 --- a/app/models/packages/rpm/repository_file.rb +++ b/app/models/packages/rpm/repository_file.rb @@ -8,6 +8,8 @@ module Packages include Packages::Installable INSTALLABLE_STATUSES = [:default].freeze + FILELISTS_FILENAME = 'filelists.xml' + FILELISTS_SIZE_LIMITATION = 20.megabytes enum status: { default: 0, pending_destruction: 1, processing: 2, error: 3 } @@ -20,6 +22,14 @@ module Packages mount_file_store_uploader Packages::Rpm::RepositoryFileUploader update_project_statistics project_statistics_name: :packages_size + + def self.has_oversized_filelists?(project_id:) + where( + project_id: project_id, + file_name: FILELISTS_FILENAME, + size: [FILELISTS_SIZE_LIMITATION..] + ).exists? + end end end end diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb index c1056d4f6cb..cf0f0f9e92f 100644 --- a/app/models/pages/lookup_path.rb +++ b/app/models/pages/lookup_path.rb @@ -19,11 +19,13 @@ module Pages def access_control project.private_pages? end + strong_memoize_attr :access_control def https_only domain_https = domain ? domain.https? : true project.pages_https_only? && domain_https end + strong_memoize_attr :https_only def source return unless deployment&.file @@ -41,6 +43,7 @@ module Pages file_count: deployment.file_count } end + strong_memoize_attr :source def prefix if project.pages_group_root? @@ -49,6 +52,7 @@ module Pages project.full_path.delete_prefix(trim_prefix) + '/' end end + strong_memoize_attr :prefix private diff --git a/app/models/pages/virtual_domain.rb b/app/models/pages/virtual_domain.rb index 119cc7fc166..fafbe449c8c 100644 --- a/app/models/pages/virtual_domain.rb +++ b/app/models/pages/virtual_domain.rb @@ -28,6 +28,7 @@ module Pages paths.sort_by(&:prefix).reverse end + # cache_key is required by #present_cached in ::API::Internal::Pages def cache_key @cache_key ||= cache&.cache_key end diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 328c67a0711..4e3f4b0c328 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -17,6 +17,7 @@ class PagesDomain < ApplicationRecord has_many :acme_orders, class_name: "PagesDomainAcmeOrder" has_many :serverless_domain_clusters, class_name: 'Serverless::DomainCluster', inverse_of: :pages_domain + after_initialize :set_verification_code before_validation :clear_auto_ssl_failure, unless: :auto_ssl_enabled validates :domain, hostname: { allow_numeric_hostname: true } @@ -44,8 +45,6 @@ class PagesDomain < ApplicationRecord key: Settings.attr_encrypted_db_key_base, algorithm: 'aes-256-cbc' - after_initialize :set_verification_code - scope :for_project, ->(project) { where(project: project) } scope :enabled, -> { where('enabled_until >= ?', Time.current) } diff --git a/app/models/performance_monitoring/prometheus_dashboard.rb b/app/models/performance_monitoring/prometheus_dashboard.rb index 4804f620a99..37bf080ae49 100644 --- a/app/models/performance_monitoring/prometheus_dashboard.rb +++ b/app/models/performance_monitoring/prometheus_dashboard.rb @@ -53,8 +53,6 @@ module PerformanceMonitoring # This method is planned to be refactored as a part of https://gitlab.com/gitlab-org/gitlab/-/issues/219398 # implementation. For new existing logic was reused to faster deliver MVC def schema_validation_warnings - return run_custom_validation.map(&:message) if Feature.enabled?(:metrics_dashboard_exhaustive_validations, environment&.project) - self.class.from_json(reload_schema) [] rescue Gitlab::Metrics::Dashboard::Errors::LayoutError => e @@ -65,11 +63,6 @@ module PerformanceMonitoring private - def run_custom_validation - Gitlab::Metrics::Dashboard::Validator - .errors(reload_schema, dashboard_path: path, project: environment&.project) - end - # dashboard finder methods are somehow limited, #find includes checking if # user is authorised to view selected dashboard, but modifies schema, which in some cases may # cause false positives returned from validation, and #find_raw does not authorise users diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 3126dba9d6d..887ef36cc17 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -18,6 +18,7 @@ class PersonalAccessToken < ApplicationRecord belongs_to :user + after_initialize :set_default_scopes, if: :persisted? before_save :ensure_token scope :active, -> { not_revoked.not_expired } @@ -41,8 +42,6 @@ class PersonalAccessToken < ApplicationRecord validates :scopes, presence: true validate :validate_scopes - after_initialize :set_default_scopes, if: :persisted? - def revoke! update!(revoked: true) end diff --git a/app/models/postgresql/detached_partition.rb b/app/models/postgresql/detached_partition.rb index b0dd52c9657..d26778957d5 100644 --- a/app/models/postgresql/detached_partition.rb +++ b/app/models/postgresql/detached_partition.rb @@ -7,5 +7,9 @@ module Postgresql def fully_qualified_table_name "#{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}.#{table_name}" end + + def table_schema + Gitlab::Database::GitlabSchema.table_schema(table_name) + end end end diff --git a/app/models/programming_language.rb b/app/models/programming_language.rb index 06e3034e56a..4156c672518 100644 --- a/app/models/programming_language.rb +++ b/app/models/programming_language.rb @@ -10,4 +10,22 @@ class ProgrammingLanguage < ApplicationRecord sanitized_names = names.map(&method(:sanitize_sql_like)) where(arel_table[:name].matches_any(sanitized_names)) end + + def self.most_popular(limit = 25) + sql = <<~SQL + SELECT + mcv + FROM + pg_stats + CROSS JOIN LATERAL + unnest(most_common_vals::text::int[]) mt(mcv) + WHERE + tablename = 'repository_languages' and attname='programming_language_id' + LIMIT + $1 + SQL + ids = connection.exec_query(sql, 'SQL', [limit]).rows.flatten + + where(id: ids).order(:name) + end end diff --git a/app/models/project.rb b/app/models/project.rb index 0c4f76fb2b9..73dbb55a07b 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -89,68 +89,56 @@ class Project < ApplicationRecord cache_markdown_field :description, pipeline: :description - default_value_for :packages_enabled, true - default_value_for :archived, false - default_value_for :resolve_outdated_diff_discussions, false - default_value_for(:repository_storage) do - Repository.pick_storage_shard - end + attribute :packages_enabled, default: true + attribute :archived, default: false + attribute :resolve_outdated_diff_discussions, default: false + attribute :repository_storage, default: -> { Repository.pick_storage_shard } + attribute :shared_runners_enabled, default: -> { Gitlab::CurrentSettings.shared_runners_enabled } + attribute :only_allow_merge_if_all_discussions_are_resolved, default: false + attribute :remove_source_branch_after_merge, default: true + attribute :autoclose_referenced_issues, default: true + attribute :ci_config_path, default: -> { Gitlab::CurrentSettings.default_ci_config_path } - default_value_for(:shared_runners_enabled) { Gitlab::CurrentSettings.shared_runners_enabled } default_value_for :issues_enabled, gitlab_config_features.issues default_value_for :merge_requests_enabled, gitlab_config_features.merge_requests default_value_for :builds_enabled, gitlab_config_features.builds default_value_for :wiki_enabled, gitlab_config_features.wiki default_value_for :snippets_enabled, gitlab_config_features.snippets - default_value_for :only_allow_merge_if_all_discussions_are_resolved, false - default_value_for :remove_source_branch_after_merge, true - default_value_for :autoclose_referenced_issues, true - default_value_for(:ci_config_path) { Gitlab::CurrentSettings.default_ci_config_path } add_authentication_token_field :runners_token, encrypted: -> { Feature.enabled?(:projects_tokens_optional_encryption) ? :optional : :required }, prefix: RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX + # Storage specific hooks + after_initialize :use_hashed_storage before_validation :mark_remote_mirrors_for_removal, if: -> { RemoteMirror.table_exists? } - before_save :ensure_runners_token before_validation :ensure_project_namespace_in_sync - before_validation :set_package_registry_access_level, if: :packages_enabled_changed? before_validation :remove_leading_spaces_on_name - - after_save :update_project_statistics, if: :saved_change_to_namespace_id? - - after_save :schedule_sync_event_worker, if: -> { saved_change_to_id? || saved_change_to_namespace_id? } - - after_save :create_import_state, if: ->(project) { project.import? && project.import_state.nil? } - - after_save :save_topics - - after_save :reload_project_namespace_details + after_validation :check_pending_delete + before_save :ensure_runners_token after_create -> { create_or_load_association(:project_feature) } - after_create -> { create_or_load_association(:ci_cd_settings) } - after_create -> { create_or_load_association(:container_expiration_policy) } - after_create -> { create_or_load_association(:pages_metadatum) } - after_create :set_timestamps_for_create + after_create :check_repository_absence! after_update :update_forks_visibility_level - before_destroy :remove_private_deploy_keys + after_destroy :remove_exports + after_save :update_project_statistics, if: :saved_change_to_namespace_id? - use_fast_destroy :build_trace_chunks + after_save :schedule_sync_event_worker, if: -> { saved_change_to_id? || saved_change_to_namespace_id? } - after_destroy :remove_exports + after_save :create_import_state, if: ->(project) { project.import? && project.import_state.nil? } - after_validation :check_pending_delete + after_save :save_topics - # Storage specific hooks - after_initialize :use_hashed_storage - after_create :check_repository_absence! + after_save :reload_project_namespace_details + + use_fast_destroy :build_trace_chunks has_many :project_topics, -> { order(:id) }, class_name: 'Projects::ProjectTopic' has_many :topics, through: :project_topics, class_name: 'Projects::Topic' @@ -196,7 +184,6 @@ class Project < ApplicationRecord has_one :emails_on_push_integration, class_name: 'Integrations::EmailsOnPush' has_one :ewm_integration, class_name: 'Integrations::Ewm' has_one :external_wiki_integration, class_name: 'Integrations::ExternalWiki' - has_one :flowdock_integration, class_name: 'Integrations::Flowdock' has_one :hangouts_chat_integration, class_name: 'Integrations::HangoutsChat' has_one :harbor_integration, class_name: 'Integrations::Harbor' has_one :irker_integration, class_name: 'Integrations::Irker' @@ -231,6 +218,17 @@ class Project < ApplicationRecord has_one :fork_network_member has_one :fork_network, through: :fork_network_member has_one :forked_from_project, through: :fork_network_member + + # Projects with a very large number of notes may time out destroying them + # through the foreign key. Additionally, the deprecated attachment uploader + # for notes requires us to use dependent: :destroy to avoid orphaning uploaded + # files. + # + # https://gitlab.com/gitlab-org/gitlab/-/issues/207222 + # Order of this association is important for project deletion. + # has_many :notes` should be the first association among all `has_many` associations. + has_many :notes, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :forked_to_members, class_name: 'ForkNetworkMember', foreign_key: 'forked_from_project_id' has_many :forks, through: :forked_to_members, source: :project, inverse_of: :forked_from_project has_many :fork_network_projects, through: :fork_network, source: :projects @@ -259,25 +257,30 @@ class Project < ApplicationRecord has_one :service_desk_setting, class_name: 'ServiceDeskSetting' # Merge requests for target project should be removed with it - has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project + has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :merge_request_metrics, foreign_key: 'target_project', class_name: 'MergeRequest::Metrics', inverse_of: :target_project has_many :source_of_merge_requests, foreign_key: 'source_project_id', class_name: 'MergeRequest' - has_many :issues + has_many :issues, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :incident_management_issuable_escalation_statuses, through: :issues, inverse_of: :project, class_name: 'IncidentManagement::IssuableEscalationStatus' has_many :incident_management_timeline_event_tags, inverse_of: :project, class_name: 'IncidentManagement::TimelineEventTag' - has_many :labels, class_name: 'ProjectLabel' - has_many :integrations + has_many :labels, class_name: 'ProjectLabel', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :events has_many :milestones - # Projects with a very large number of notes may time out destroying them - # through the foreign key. Additionally, the deprecated attachment uploader - # for notes requires us to use dependent: :destroy to avoid orphaning uploaded - # files. - # - # https://gitlab.com/gitlab-org/gitlab/-/issues/207222 - has_many :notes, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - + has_many :integrations + has_many :alert_hooks_integrations, -> { alert_hooks }, class_name: 'Integration' + has_many :archive_trace_hooks_integrations, -> { archive_trace_hooks }, class_name: 'Integration' + has_many :confidential_issue_hooks_integrations, -> { confidential_issue_hooks }, class_name: 'Integration' + has_many :confidential_note_hooks_integrations, -> { confidential_note_hooks }, class_name: 'Integration' + has_many :deployment_hooks_integrations, -> { deployment_hooks }, class_name: 'Integration' + has_many :issue_hooks_integrations, -> { issue_hooks }, class_name: 'Integration' + has_many :job_hooks_integrations, -> { job_hooks }, class_name: 'Integration' + has_many :merge_request_hooks_integrations, -> { merge_request_hooks }, class_name: 'Integration' + has_many :note_hooks_integrations, -> { note_hooks }, class_name: 'Integration' + has_many :pipeline_hooks_integrations, -> { pipeline_hooks }, class_name: 'Integration' + has_many :push_hooks_integrations, -> { push_hooks }, class_name: 'Integration' + has_many :tag_push_hooks_integrations, -> { tag_push_hooks }, class_name: 'Integration' + has_many :wiki_page_hooks_integrations, -> { wiki_page_hooks }, class_name: 'Integration' has_many :snippets, class_name: 'ProjectSnippet' has_many :hooks, class_name: 'ProjectHook' has_many :protected_branches @@ -380,7 +383,7 @@ class Project < ApplicationRecord has_one :auto_devops, class_name: 'ProjectAutoDevops', inverse_of: :project, autosave: true has_many :custom_attributes, class_name: 'ProjectCustomAttribute' - has_many :project_badges, class_name: 'ProjectBadge' + has_many :project_badges, class_name: 'ProjectBadge', inverse_of: :project has_one :ci_cd_settings, class_name: 'ProjectCiCdSetting', inverse_of: :project, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :remote_mirrors, inverse_of: :project @@ -650,6 +653,11 @@ class Project < ApplicationRecord .where(repository_languages: { programming_language_id: lang_id_query }) end + scope :with_programming_language_id, ->(language_id) do + joins(:repository_languages) + .where(repository_languages: { programming_language_id: language_id }) + end + scope :service_desk_enabled, -> { where(service_desk_enabled: true) } scope :with_builds_enabled, -> { with_feature_enabled(:builds) } scope :with_issues_enabled, -> { with_feature_enabled(:issues) } @@ -742,6 +750,29 @@ class Project < ApplicationRecord end end + # Defines instance methods: + # + # - only_allow_merge_if_pipeline_succeeds?(inherit_group_setting: false) + # - allow_merge_on_skipped_pipeline?(inherit_group_setting: false) + # - only_allow_merge_if_all_discussions_are_resolved?(inherit_group_setting: false) + # - only_allow_merge_if_pipeline_succeeds_locked? + # - allow_merge_on_skipped_pipeline_locked? + # - only_allow_merge_if_all_discussions_are_resolved_locked? + def self.cascading_with_parent_namespace(attribute) + # method overriden in EE + define_method("#{attribute}?") do |inherit_group_setting: false| + self.public_send(attribute) # rubocop:disable GitlabSecurity/PublicSend + end + + define_method("#{attribute}_locked?") do + false + end + end + + cascading_with_parent_namespace :only_allow_merge_if_pipeline_succeeds + cascading_with_parent_namespace :allow_merge_on_skipped_pipeline + cascading_with_parent_namespace :only_allow_merge_if_all_discussions_are_resolved + def self.with_feature_available_for_user(feature, user) with_project_feature.merge(ProjectFeature.with_feature_available_for_user(feature, user)) end @@ -1691,8 +1722,14 @@ class Project < ApplicationRecord def execute_integrations(data, hooks_scope = :push_hooks) # Call only service hooks that are active for this scope run_after_commit_or_now do - integrations.public_send(hooks_scope).each do |integration| # rubocop:disable GitlabSecurity/PublicSend - integration.async_execute(data) + if use_integration_relations? + association("#{hooks_scope}_integrations").reader.each do |integration| + integration.async_execute(data) + end + else + integrations.public_send(hooks_scope).each do |integration| # rubocop:disable GitlabSecurity/PublicSend + integration.async_execute(data) + end end end end @@ -2301,6 +2338,7 @@ class Project < ApplicationRecord .append(key: 'CI_PROJECT_PATH', value: full_path) .append(key: 'CI_PROJECT_PATH_SLUG', value: full_path_slug) .append(key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path) + .append(key: 'CI_PROJECT_NAMESPACE_ID', value: namespace.id.to_s) .append(key: 'CI_PROJECT_ROOT_NAMESPACE', value: namespace.root_ancestor.path) .append(key: 'CI_PROJECT_URL', value: web_url) .append(key: 'CI_PROJECT_VISIBILITY', value: Gitlab::VisibilityLevel.string_level(visibility_level)) @@ -2700,7 +2738,13 @@ class Project < ApplicationRecord def access_request_approvers_to_be_notified access_request_approvers = members.owners_and_maintainers - access_request_approvers.connected_to_user.order_recent_sign_in.limit(Member::ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) + recipients = access_request_approvers.connected_to_user.order_recent_sign_in.limit(Member::ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) + + if recipients.blank? + recipients = group.access_request_approvers_to_be_notified + end + + recipients end def pages_lookup_path(trim_prefix: nil, domain: nil) @@ -2994,6 +3038,10 @@ class Project < ApplicationRecord group&.work_items_feature_flag_enabled? || Feature.enabled?(:work_items, self) end + def work_items_mvc_feature_flag_enabled? + group&.work_items_mvc_feature_flag_enabled? || Feature.enabled?(:work_items_mvc) + end + def work_items_mvc_2_feature_flag_enabled? group&.work_items_mvc_2_feature_flag_enabled? || Feature.enabled?(:work_items_mvc_2) end @@ -3321,6 +3369,12 @@ class Project < ApplicationRecord ProjectFeature::PRIVATE end end + + def use_integration_relations? + strong_memoize(:use_integration_relations) do + Feature.enabled?(:cache_project_integrations, self) + end + end end Project.prepend_mod_with('Project') diff --git a/app/models/project_export_job.rb b/app/models/project_export_job.rb index decc71ee193..d26ce5465cd 100644 --- a/app/models/project_export_job.rb +++ b/app/models/project_export_job.rb @@ -1,11 +1,24 @@ # frozen_string_literal: true class ProjectExportJob < ApplicationRecord + include EachBatch + + EXPIRES_IN = 7.days + belongs_to :project has_many :relation_exports, class_name: 'Projects::ImportExport::RelationExport' validates :project, :jid, :status, presence: true + STATUS = { + queued: 0, + started: 1, + finished: 2, + failed: 3 + }.freeze + + scope :prunable, -> { where("updated_at < ?", EXPIRES_IN.ago) } + state_machine :status, initial: :queued do event :start do transition [:queued] => :started @@ -19,9 +32,17 @@ class ProjectExportJob < ApplicationRecord transition [:queued, :started] => :failed end - state :queued, value: 0 - state :started, value: 1 - state :finished, value: 2 - state :failed, value: 3 + state :queued, value: STATUS[:queued] + state :started, value: STATUS[:started] + state :finished, value: STATUS[:finished] + state :failed, value: STATUS[:failed] + end + + class << self + def prune_expired_jobs + prunable.each_batch do |relation| # rubocop:disable Style/SymbolProc + relation.delete_all + end + end end end diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index 0570be85ad1..506f6305791 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -11,21 +11,21 @@ class ProjectStatistics < ApplicationRecord attribute :snippets_size, default: 0 counter_attribute :build_artifacts_size + counter_attribute :packages_size - counter_attribute_after_flush do |project_statistic| - project_statistic.refresh_storage_size! + counter_attribute_after_commit do |project_statistics| + project_statistics.refresh_storage_size! - Namespaces::ScheduleAggregationWorker.perform_async(project_statistic.namespace_id) + Namespaces::ScheduleAggregationWorker.perform_async(project_statistics.namespace_id) end before_save :update_storage_size COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count, :snippets_size, :uploads_size, :container_registry_size].freeze - INCREMENTABLE_COLUMNS = { - packages_size: %i[storage_size], - pipeline_artifacts_size: %i[storage_size], - snippets_size: %i[storage_size] - }.freeze + INCREMENTABLE_COLUMNS = [ + :pipeline_artifacts_size, + :snippets_size + ].freeze NAMESPACE_RELATABLE_COLUMNS = [:repository_size, :wiki_size, :lfs_objects_size, :uploads_size, :container_registry_size].freeze STORAGE_SIZE_COMPONENTS = [ :repository_size, @@ -120,35 +120,27 @@ class ProjectStatistics < ApplicationRecord # we have to update the storage_size separately. # # For counter attributes, storage_size will be refreshed after the counter is flushed, - # through counter_attribute_after_flush + # through counter_attribute_after_commit # # For non-counter attributes, storage_size is updated depending on key => [columns] in INCREMENTABLE_COLUMNS def self.increment_statistic(project, key, amount) - raise ArgumentError, "Cannot increment attribute: #{key}" unless incrementable_attribute?(key) - return if amount == 0 - project.statistics.try do |project_statistics| - if counter_attribute_enabled?(key) - project_statistics.delayed_increment_counter(key, amount) - else - project_statistics.legacy_increment_statistic(key, amount) - end + project_statistics.increment_statistic(key, amount) end end - def self.incrementable_attribute?(key) - INCREMENTABLE_COLUMNS.key?(key) || counter_attribute_enabled?(key) - end - - def legacy_increment_statistic(key, amount) - increment_columns!(key, amount) + def increment_statistic(key, amount) + raise ArgumentError, "Cannot increment attribute: #{key}" unless incrementable_attribute?(key) - Namespaces::ScheduleAggregationWorker.perform_async( # rubocop: disable CodeReuse/Worker - project.namespace_id) + increment_counter(key, amount) end private + def incrementable_attribute?(key) + INCREMENTABLE_COLUMNS.include?(key) || counter_attribute_enabled?(key) + end + def storage_size_components STORAGE_SIZE_COMPONENTS end @@ -157,16 +149,6 @@ class ProjectStatistics < ApplicationRecord storage_size_components.map { |component| "COALESCE (#{component}, 0)" }.join(' + ').freeze end - def increment_columns!(key, amount) - increments = { key => amount } - additional = INCREMENTABLE_COLUMNS.fetch(key, []) - additional.each do |column| - increments[column] = amount - end - - update_counters_with_lease(increments) - end - def schedule_namespace_aggregation_worker run_after_commit do Namespaces::ScheduleAggregationWorker.perform_async(project.namespace_id) diff --git a/app/models/projects/forks/divergence_counts.rb b/app/models/projects/forks/divergence_counts.rb new file mode 100644 index 00000000000..7d630b00083 --- /dev/null +++ b/app/models/projects/forks/divergence_counts.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Projects + module Forks + # Class for calculating the divergence of a fork with the source project + class DivergenceCounts + LATEST_COMMITS_COUNT = 10 + EXPIRATION_TIME = 8.hours + + def initialize(project, ref) + @project = project + @fork_repo = project.repository + @source_repo = project.fork_source.repository + @ref = ref + end + + def counts + ahead, behind = divergence_counts + + { ahead: ahead, behind: behind } + end + + private + + attr_reader :project, :fork_repo, :source_repo, :ref + + def cache_key + @cache_key ||= ['project_forks', project.id, ref, 'divergence_counts'] + end + + def divergence_counts + fork_sha = fork_repo.commit(ref).sha + source_sha = source_repo.commit.sha + + cached_source_sha, cached_fork_sha, counts = Rails.cache.read(cache_key) + return counts if cached_source_sha == source_sha && cached_fork_sha == fork_sha + + counts = calculate_divergence_counts(fork_sha, source_sha) + + Rails.cache.write(cache_key, [source_sha, fork_sha, counts], expires_in: EXPIRATION_TIME) + + counts + end + + def calculate_divergence_counts(fork_sha, source_sha) + # If the upstream latest commit exists in the fork repo, then + # it's possible to calculate divergence counts within the fork repository. + return fork_repo.diverging_commit_count(fork_sha, source_sha) if fork_repo.commit(source_sha) + + # Otherwise, we need to find a commit that exists both in the fork and upstream + # in order to use this commit as a base for calculating divergence counts. + # Considering the fact that a user usually creates a fork to contribute to the upstream, + # it is expected that they have a limited number of commits ahead of upstream. + # Let's take the latest N commits and check their existence upstream. + last_commits_shas = fork_repo.commits(ref, limit: LATEST_COMMITS_COUNT).map(&:sha) + existence_hash = source_repo.check_objects_exist(last_commits_shas) + first_matched_commit_sha = last_commits_shas.find { |sha| existence_hash[sha] } + + # If we can't find such a commit, we return early and tell the user that the branches + # have diverged and action is required. + return unless first_matched_commit_sha + + # Otherwise, we use upstream to calculate divergence counts from the matched commit + ahead, behind = source_repo.diverging_commit_count(first_matched_commit_sha, source_sha) + # And add the number of commits a fork is ahead of the first matched commit + ahead += last_commits_shas.index(first_matched_commit_sha) + + [ahead, behind] + end + end + end +end diff --git a/app/models/projects/import_export/relation_export_upload.rb b/app/models/projects/import_export/relation_export_upload.rb index 965dc39d19f..12cfb3415d8 100644 --- a/app/models/projects/import_export/relation_export_upload.rb +++ b/app/models/projects/import_export/relation_export_upload.rb @@ -4,7 +4,6 @@ module Projects module ImportExport class RelationExportUpload < ApplicationRecord include WithUploads - include ObjectStorage::BackgroundMove self.table_name = 'project_relation_export_uploads' diff --git a/app/models/prometheus_alert.rb b/app/models/prometheus_alert.rb index 9080f3d9de1..59440947d71 100644 --- a/app/models/prometheus_alert.rb +++ b/app/models/prometheus_alert.rb @@ -20,8 +20,8 @@ class PrometheusAlert < ApplicationRecord has_many :related_issues, through: :prometheus_alert_events has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :prometheus_alert - after_save :clear_prometheus_adapter_cache! after_destroy :clear_prometheus_adapter_cache! + after_save :clear_prometheus_adapter_cache! validates :environment, :project, :prometheus_metric, :threshold, :operator, presence: true validates :runbook_url, length: { maximum: 255 }, allow_blank: true, diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 80967c1b072..c59ef4cd80b 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -14,10 +14,12 @@ class ProtectedBranch < ApplicationRecord scope :allowing_force_push, -> { where(allow_force_push: true) } - scope :get_ids_by_name, -> (name) { where(name: name).pluck(:id) } - protected_ref_access_levels :merge, :push + def self.get_ids_by_name(name) + where(name: name).pluck(:id) + end + def self.protected_ref_accessible_to?(ref, user, project:, action:, protected_refs: nil) # Maintainers, owners and admins are allowed to create the default branch diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb index f8d500e106b..b830cf313af 100644 --- a/app/models/remote_mirror.rb +++ b/app/models/remote_mirror.rb @@ -20,12 +20,11 @@ class RemoteMirror < ApplicationRecord belongs_to :project, inverse_of: :remote_mirrors - validates :url, presence: true, public_url: { schemes: %w(ssh git http https), allow_blank: true, enforce_user: true } - - after_save :set_override_remote_mirror_available, unless: -> { Gitlab::CurrentSettings.current_application_settings.mirror_available } - after_update :reset_fields, if: :saved_change_to_mirror_url? + validates :url, presence: true, public_url: { schemes: Project::VALID_MIRROR_PROTOCOLS, allow_blank: true, enforce_user: true } before_validation :store_credentials + after_update :reset_fields, if: :saved_change_to_mirror_url? + after_save :set_override_remote_mirror_available, unless: -> { Gitlab::CurrentSettings.current_application_settings.mirror_available } scope :enabled, -> { where(enabled: true) } scope :started, -> { with_update_status(:started) } diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb index a1753df9294..a1426540cf5 100644 --- a/app/models/resource_label_event.rb +++ b/app/models/resource_label_event.rb @@ -14,8 +14,8 @@ class ResourceLabelEvent < ResourceEvent validates :label, presence: { unless: :importing? }, on: :create validate :exactly_one_issuable, unless: :importing? - after_save :expire_etag_cache after_destroy :expire_etag_cache + after_save :expire_etag_cache enum action: { add: 1, diff --git a/app/models/service_desk_setting.rb b/app/models/service_desk_setting.rb index 6dd7415d928..738f18ca5e3 100644 --- a/app/models/service_desk_setting.rb +++ b/app/models/service_desk_setting.rb @@ -53,7 +53,7 @@ class ServiceDeskSetting < ApplicationRecord def projects_with_same_slug_and_key_exists? return false unless project_key - settings = self.class.with_project_key(project_key).preload(:project) + settings = self.class.with_project_key(project_key).where.not(project_id: project_id).preload(:project) project_slug = self.project.full_path_slug settings.any? do |setting| diff --git a/app/models/snippet_statistics.rb b/app/models/snippet_statistics.rb index 6fb6f0ef713..44bff0e1e5b 100644 --- a/app/models/snippet_statistics.rb +++ b/app/models/snippet_statistics.rb @@ -12,8 +12,8 @@ class SnippetStatistics < ApplicationRecord delegate :repository, :project, :project_id, to: :snippet - after_save :update_author_root_storage_statistics, if: :update_author_root_storage_statistics? after_destroy :update_author_root_storage_statistics, unless: :project_snippet? + after_save :update_author_root_storage_statistics, if: :update_author_root_storage_statistics? def update_commit_count self.commit_count = repository.commit_count diff --git a/app/models/synthetic_note.rb b/app/models/synthetic_note.rb index dea7165af9f..a60c0d2f3bc 100644 --- a/app/models/synthetic_note.rb +++ b/app/models/synthetic_note.rb @@ -10,6 +10,7 @@ class SyntheticNote < Note system: true, author: event.user, created_at: event.created_at, + updated_at: event.created_at, discussion_id: event.discussion_id, noteable: resource, event: event, diff --git a/app/models/todo.rb b/app/models/todo.rb index f2fa0df852a..32ec4accb4b 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -19,6 +19,7 @@ class Todo < ApplicationRecord DIRECTLY_ADDRESSED = 7 MERGE_TRAIN_REMOVED = 8 # This is an EE-only feature REVIEW_REQUESTED = 9 + MEMBER_ACCESS_REQUESTED = 10 ACTION_NAMES = { ASSIGNED => :assigned, @@ -29,10 +30,11 @@ class Todo < ApplicationRecord APPROVAL_REQUIRED => :approval_required, UNMERGEABLE => :unmergeable, DIRECTLY_ADDRESSED => :directly_addressed, - MERGE_TRAIN_REMOVED => :merge_train_removed + MERGE_TRAIN_REMOVED => :merge_train_removed, + MEMBER_ACCESS_REQUESTED => :member_access_requested }.freeze - ACTIONS_MULTIPLE_ALLOWED = [Todo::MENTIONED, Todo::DIRECTLY_ADDRESSED].freeze + ACTIONS_MULTIPLE_ALLOWED = [Todo::MENTIONED, Todo::DIRECTLY_ADDRESSED, Todo::MEMBER_ACCESS_REQUESTED].freeze belongs_to :author, class_name: "User" belongs_to :note @@ -198,6 +200,16 @@ class Todo < ApplicationRecord action == MERGE_TRAIN_REMOVED end + def member_access_requested? + action == MEMBER_ACCESS_REQUESTED + end + + def access_request_url + return "" unless self.target_type == 'Namespace' + + Gitlab::Routing.url_helpers.group_group_members_url(self.target, tab: 'access_requests') + end + def done? state == 'done' end @@ -209,6 +221,8 @@ class Todo < ApplicationRecord def body if note.present? note.note + elsif member_access_requested? + target.full_path else target.title end @@ -246,6 +260,8 @@ class Todo < ApplicationRecord def target_reference if for_commit? target.reference_link_text + elsif member_access_requested? + target.full_path else target.to_reference end diff --git a/app/models/upload.rb b/app/models/upload.rb index ac7ebb31abc..a4fbc703146 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -16,14 +16,13 @@ class Upload < ApplicationRecord scope :with_files_stored_locally, -> { where(store: ObjectStorage::Store::LOCAL) } scope :with_files_stored_remotely, -> { where(store: ObjectStorage::Store::REMOTE) } - before_save :calculate_checksum!, if: :foreground_checksummable? - after_commit :schedule_checksum, if: :needs_checksum? - - after_commit :update_project_statistics, on: [:create, :destroy], if: :project? - + before_save :calculate_checksum!, if: :foreground_checksummable? # as the FileUploader is not mounted, the default CarrierWave ActiveRecord # hooks are not executed and the file will not be deleted after_destroy :delete_file!, if: -> { uploader_class <= FileUploader } + after_commit :schedule_checksum, if: :needs_checksum? + + after_commit :update_project_statistics, on: [:create, :destroy], if: :project? class << self def inner_join_local_uploads_projects diff --git a/app/models/user.rb b/app/models/user.rb index b4b8a7ef7ad..ba3f7922c9c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -46,6 +46,8 @@ class User < ApplicationRecord MAX_USERNAME_LENGTH = 255 MIN_USERNAME_LENGTH = 2 + MAX_LIMIT_FOR_ASSIGNEED_ISSUES_COUNT = 100 + SECONDARY_EMAIL_ATTRIBUTES = [ :commit_email, :notification_email, @@ -58,16 +60,16 @@ class User < ApplicationRecord add_authentication_token_field :feed_token add_authentication_token_field :static_object_token, encrypted: :optional - default_value_for :admin, false - default_value_for(:external) { Gitlab::CurrentSettings.user_default_external } - default_value_for(:can_create_group) { Gitlab::CurrentSettings.can_create_group } - default_value_for :can_create_team, false - default_value_for :hide_no_ssh_key, false - default_value_for :hide_no_password, false - default_value_for :project_view, :files - default_value_for :notified_of_own_activity, false - default_value_for :preferred_language, I18n.default_locale - default_value_for :theme_id, gitlab_config.default_theme + attribute :admin, default: false + attribute :external, default: -> { Gitlab::CurrentSettings.user_default_external } + attribute :can_create_group, default: -> { Gitlab::CurrentSettings.can_create_group } + attribute :can_create_team, default: false + attribute :hide_no_ssh_key, default: false + attribute :hide_no_password, default: false + attribute :project_view, default: :files + attribute :notified_of_own_activity, default: false + attribute :preferred_language, default: -> { I18n.default_locale } + attribute :theme_id, default: -> { gitlab_config.default_theme } attr_encrypted :otp_secret, key: Gitlab::Application.secrets.otp_key_base, @@ -298,16 +300,17 @@ class User < ApplicationRecord validates :website_url, allow_blank: true, url: true, if: :website_url_changed? + after_initialize :set_projects_limit before_validation :sanitize_attrs + before_validation :ensure_namespace_correct + after_validation :set_username_errors before_save :default_private_profile_to_false before_save :ensure_incoming_email_token before_save :ensure_user_rights_and_limits, if: ->(user) { user.new_record? || user.external_changed? } before_save :skip_reconfirmation!, if: ->(user) { user.email_changed? && user.read_only_attribute?(:email) } before_save :check_for_verified_email, if: ->(user) { user.email_changed? && !user.new_record? } - before_validation :ensure_namespace_correct before_save :ensure_namespace_correct # in case validation is skipped before_save :ensure_user_detail_assigned - after_validation :set_username_errors after_update :username_changed_hook, if: :saved_change_to_username? after_destroy :post_destroy_hook after_destroy :remove_key_cache @@ -328,8 +331,6 @@ class User < ApplicationRecord update_invalid_gpg_signatures if previous_changes.key?('email') end - after_initialize :set_projects_limit - # User's Layout preference enum layout: { fixed: 0, fluid: 1 } @@ -360,6 +361,7 @@ class User < ApplicationRecord :diffs_deletion_color, :diffs_deletion_color=, :diffs_addition_color, :diffs_addition_color=, :use_legacy_web_ide, :use_legacy_web_ide=, + :use_new_navigation, :use_new_navigation=, to: :user_preference delegate :path, to: :namespace, allow_nil: true, prefix: true @@ -376,6 +378,14 @@ class User < ApplicationRecord accepts_nested_attributes_for :credit_card_validation, update_only: true, allow_destroy: true state_machine :state, initial: :active do + # state_machine uses this method at class loading time to fetch the default + # value for the `state` column but in doing so it also evaluates all other + # columns default values which could trigger the recursive generation of + # ApplicationSetting records. We're setting it to `nil` here because we + # don't have a database default for the `state` column. + # + def owner_class_attribute_default; end + event :block do transition active: :blocked transition deactivated: :blocked @@ -811,7 +821,7 @@ class User < ApplicationRecord # Returns a user for the given SSH key. def find_by_ssh_key_id(key_id) - find_by('EXISTS (?)', Key.select(1).where('keys.user_id = users.id').where(id: key_id)) + find_by('EXISTS (?)', Key.select(1).where('keys.user_id = users.id').auth.where(id: key_id)) end def find_by_full_path(path, follow_redirects: false) @@ -896,6 +906,18 @@ class User < ApplicationRecord end end + def admin_bot + email_pattern = "admin-bot%s@#{Settings.gitlab.host}" + + unique_internal(where(user_type: :admin_bot), 'GitLab-Admin-Bot', email_pattern) do |u| + u.bio = 'Admin bot used for tasks that require admin privileges' + u.name = 'GitLab Admin Bot' + u.avatar = bot_avatar(image: 'admin-bot.png') + u.admin = true + u.confirmed_at = Time.zone.now + end + end + # Return true if there is only single non-internal user in the deployment, # ghost user is ignored. def single_user? @@ -1759,12 +1781,10 @@ class User < ApplicationRecord end def ci_owned_runners - @ci_owned_runners ||= begin - Ci::Runner + @ci_owned_runners ||= Ci::Runner .from_union([ci_owned_project_runners_from_project_members, ci_owned_project_runners_from_group_members, ci_owned_group_runners]) - end end def owns_runner?(runner) @@ -1773,7 +1793,11 @@ class User < ApplicationRecord def notification_email_for(notification_group) # Return group-specific email address if present, otherwise return global notification email address - notification_group&.notification_email_for(self) || notification_email_or_default + group_email = if notification_group && notification_group.respond_to?(:notification_email_for) + notification_group.notification_email_for(self) + end + + group_email || notification_email_or_default end def notification_settings_for(source, inherit: false) @@ -1866,6 +1890,7 @@ class User < ApplicationRecord def invalidate_issue_cache_counts Rails.cache.delete(['users', id, 'assigned_open_issues_count']) + Rails.cache.delete(['users', id, 'max_assigned_open_issues_count']) if Feature.enabled?(:limit_assigned_issues_count) end def invalidate_merge_request_cache_counts @@ -2323,9 +2348,7 @@ class User < ApplicationRecord end def check_password_weakness - if Feature.enabled?(:block_weak_passwords) && - password.present? && - Security::WeakPasswords.weak_for_user?(password, self) + if password.present? && Security::WeakPasswords.weak_for_user?(password, self) errors.add(:password, _('must not contain commonly used combinations of words and letters')) end end diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb index 2e662faea6a..0570bc2f395 100644 --- a/app/models/user_detail.rb +++ b/app/models/user_detail.rb @@ -19,7 +19,7 @@ class UserDetail < ApplicationRecord validates :skype, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true validates :location, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true validates :organization, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true - validates :website_url, length: { maximum: DEFAULT_FIELD_LENGTH }, url: true, allow_blank: true + validates :website_url, length: { maximum: DEFAULT_FIELD_LENGTH }, url: true, allow_blank: true, if: :website_url_changed? before_validation :sanitize_attrs before_save :prevent_nil_bio diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index c6ebd550daf..bc2c6b526b8 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -26,10 +26,10 @@ class UserPreference < ApplicationRecord ignore_columns :experience_level, remove_with: '14.10', remove_after: '2021-03-22' - default_value_for :tab_width, value: Gitlab::TabWidth::DEFAULT, allows_nil: false - default_value_for :time_display_relative, value: true, allows_nil: false - default_value_for :time_format_in_24h, value: false, allows_nil: false - default_value_for :render_whitespace_in_code, value: false, allows_nil: false + attribute :tab_width, default: -> { Gitlab::TabWidth::DEFAULT } + attribute :time_display_relative, default: true + attribute :time_format_in_24h, default: false + attribute :render_whitespace_in_code, default: false class << self def notes_filters @@ -59,6 +59,67 @@ class UserPreference < ApplicationRecord self[notes_filter_field_for(resource)] end + def tab_width + read_attribute(:tab_width) || self.class.column_defaults['tab_width'] + end + + def tab_width=(value) + if value.nil? + default = self.class.column_defaults['tab_width'] + super(default) + else + super(value) + end + end + + def time_display_relative + value = read_attribute(:time_display_relative) + return value unless value.nil? + + self.class.column_defaults['time_display_relative'] + end + + def time_display_relative=(value) + if value.nil? + default = self.class.column_defaults['time_display_relative'] + super(default) + else + super(value) + end + end + + def time_format_in_24h + value = read_attribute(:time_format_in_24h) + return value unless value.nil? + + self.class.column_defaults['time_format_in_24h'] + end + + def time_format_in_24h=(value) + if value.nil? + default = self.class.column_defaults['time_format_in_24h'] + super(default) + else + super(value) + end + end + + def render_whitespace_in_code + value = read_attribute(:render_whitespace_in_code) + return value unless value.nil? + + self.class.column_defaults['render_whitespace_in_code'] + end + + def render_whitespace_in_code=(value) + if value.nil? + default = self.class.column_defaults['render_whitespace_in_code'] + super(default) + else + super(value) + end + end + private def notes_filter_field_for(resource) diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb index b037d07658d..3f9353214ee 100644 --- a/app/models/users/callout.rb +++ b/app/models/users/callout.rb @@ -63,7 +63,9 @@ module Users project_quality_summary_feedback: 59, # EE-only merge_request_settings_moved_callout: 60, new_top_level_group_alert: 61, - artifacts_management_page_feedback_banner: 62 + artifacts_management_page_feedback_banner: 62, + vscode_web_ide: 63, + vscode_web_ide_callout: 64 } validates :feature_name, diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb index 3e3e424e9c9..2552407fa4c 100644 --- a/app/models/users/group_callout.rb +++ b/app/models/users/group_callout.rb @@ -23,7 +23,8 @@ module Users namespace_storage_limit_banner_alert_threshold: 12, # EE-only namespace_storage_limit_banner_error_threshold: 13, # EE-only usage_quota_trial_alert: 14, # EE-only - preview_usage_quota_free_plan_alert: 15 # EE-only + preview_usage_quota_free_plan_alert: 15, # EE-only + enforcement_at_limit_alert: 16 # EE-only } validates :group, presence: true diff --git a/app/models/users/phone_number_validation.rb b/app/models/users/phone_number_validation.rb index f6123c01fd0..b9e4e908ddd 100644 --- a/app/models/users/phone_number_validation.rb +++ b/app/models/users/phone_number_validation.rb @@ -31,11 +31,17 @@ module Users validates :telesign_reference_xid, length: { maximum: 255 } + scope :for_user, -> (user_id) { where(user_id: user_id) } + def self.related_to_banned_user?(international_dial_code, phone_number) joins(:banned_user).where( international_dial_code: international_dial_code, phone_number: phone_number ).exists? end + + def validated? + validated_at.present? + end end end diff --git a/app/models/work_item.rb b/app/models/work_item.rb index ed6f9d161a6..0810c520f7e 100644 --- a/app/models/work_item.rb +++ b/app/models/work_item.rb @@ -38,6 +38,18 @@ class WorkItem < Issue end end + def ancestors + hierarchy.ancestors(hierarchy_order: :asc) + end + + def same_type_base_and_ancestors + hierarchy(same_type: true).base_and_ancestors(hierarchy_order: :asc) + end + + def same_type_descendants_depth + hierarchy(same_type: true).max_descendants_depth.to_i + end + private override :parent_link_confidentiality @@ -56,6 +68,13 @@ class WorkItem < Issue Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter.track_work_item_created_action(author: author) end + + def hierarchy(options = {}) + base = self.class.where(id: id) + base = base.where(work_item_type_id: work_item_type_id) if options[:same_type] + + ::Gitlab::WorkItems::WorkItemHierarchy.new(base, options: options) + end end WorkItem.prepend_mod diff --git a/app/models/work_items/hierarchy_restriction.rb b/app/models/work_items/hierarchy_restriction.rb new file mode 100644 index 00000000000..a253447a8db --- /dev/null +++ b/app/models/work_items/hierarchy_restriction.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module WorkItems + class HierarchyRestriction < ApplicationRecord + self.table_name = 'work_item_hierarchy_restrictions' + + belongs_to :parent_type, class_name: 'WorkItems::Type' + belongs_to :child_type, class_name: 'WorkItems::Type' + + validates :parent_type, presence: true + validates :child_type, presence: true + validates :child_type, uniqueness: { scope: :parent_type_id } + end +end diff --git a/app/models/work_items/parent_link.rb b/app/models/work_items/parent_link.rb index 13d6db3e08e..33857fb08c2 100644 --- a/app/models/work_items/parent_link.rb +++ b/app/models/work_items/parent_link.rb @@ -12,12 +12,14 @@ module WorkItems validates :work_item_parent, presence: true validates :work_item, presence: true, uniqueness: true - validate :validate_child_type - validate :validate_parent_type + validate :validate_hierarchy_restrictions + validate :validate_cyclic_reference validate :validate_same_project validate :validate_max_children validate :validate_confidentiality + scope :for_parents, ->(parent_ids) { where(work_item_parent_id: parent_ids) } + class << self def has_public_children?(parent_id) joins(:work_item).where(work_item_parent_id: parent_id, 'issues.confidential': false).exists? @@ -33,27 +35,6 @@ module WorkItems private - def validate_child_type - return unless work_item - - unless work_item.task? - errors.add :work_item, _('only Task can be assigned as a child in hierarchy.') - end - end - - def validate_parent_type - return unless work_item_parent - - base_type = work_item_parent.work_item_type.base_type.to_sym - unless PARENT_TYPES.include?(base_type) - parent_names = WorkItems::Type::BASE_TYPES.slice(*WorkItems::ParentLink::PARENT_TYPES) - .values.map { |type| type[:name] } - - errors.add :work_item_parent, _('only %{parent_types} can be parent of Task.') % - { parent_types: parent_names.to_sentence } - end - end - def validate_same_project return if work_item.nil? || work_item_parent.nil? @@ -79,5 +60,40 @@ module WorkItems "parent. Make the work item confidential and try again.") end end + + def validate_hierarchy_restrictions + return unless work_item && work_item_parent + + restriction = ::WorkItems::HierarchyRestriction + .find_by_parent_type_id_and_child_type_id(work_item_parent.work_item_type_id, work_item.work_item_type_id) + + if restriction.nil? + errors.add :work_item, _('is not allowed to add this type of parent') + return + end + + validate_depth(restriction.maximum_depth) + end + + def validate_depth(depth) + return unless depth + return if work_item.work_item_type_id != work_item_parent.work_item_type_id + + if work_item_parent.same_type_base_and_ancestors.count + work_item.same_type_descendants_depth > depth + errors.add :work_item, _('reached maximum depth') + end + end + + def validate_cyclic_reference + return unless work_item_parent&.id && work_item&.id + + if work_item.id == work_item_parent.id + errors.add :work_item, _('is not allowed to point to itself') + end + + if work_item_parent.ancestors.detect { |ancestor| work_item.id == ancestor.id } + errors.add :work_item, _('is already present in ancestors') + end + end end end diff --git a/app/models/work_items/type.rb b/app/models/work_items/type.rb index dc30899d24f..e1f6a13f7a7 100644 --- a/app/models/work_items/type.rb +++ b/app/models/work_items/type.rb @@ -10,31 +10,86 @@ module WorkItems include CacheMarkdownField + # type name is used in restrictions DB seeder to assure restrictions for + # default types are pre-filled + TYPE_NAMES = { + issue: 'Issue', + incident: 'Incident', + test_case: 'Test Case', + requirement: 'Requirement', + task: 'Task', + objective: 'Objective', + key_result: 'Key Result' + }.freeze + # Base types need to exist on the DB on app startup # This constant is used by the DB seeder # TODO - where to add new icon names created? BASE_TYPES = { - issue: { name: 'Issue', icon_name: 'issue-type-issue', enum_value: 0 }, - incident: { name: 'Incident', icon_name: 'issue-type-incident', enum_value: 1 }, - test_case: { name: 'Test Case', icon_name: 'issue-type-test-case', enum_value: 2 }, ## EE-only - requirement: { name: 'Requirement', icon_name: 'issue-type-requirements', enum_value: 3 }, ## EE-only - task: { name: 'Task', icon_name: 'issue-type-task', enum_value: 4 }, - objective: { name: 'Objective', icon_name: 'issue-type-objective', enum_value: 5 }, ## EE-only - key_result: { name: 'Key Result', icon_name: 'issue-type-keyresult', enum_value: 6 } ## EE-only + issue: { name: TYPE_NAMES[:issue], icon_name: 'issue-type-issue', enum_value: 0 }, + incident: { name: TYPE_NAMES[:incident], icon_name: 'issue-type-incident', enum_value: 1 }, + test_case: { name: TYPE_NAMES[:test_case], icon_name: 'issue-type-test-case', enum_value: 2 }, ## EE-only + requirement: { name: TYPE_NAMES[:requirement], icon_name: 'issue-type-requirements', enum_value: 3 }, ## EE-only + task: { name: TYPE_NAMES[:task], icon_name: 'issue-type-task', enum_value: 4 }, + objective: { name: TYPE_NAMES[:objective], icon_name: 'issue-type-objective', enum_value: 5 }, ## EE-only + key_result: { name: TYPE_NAMES[:key_result], icon_name: 'issue-type-keyresult', enum_value: 6 } ## EE-only }.freeze WIDGETS_FOR_TYPE = { - issue: [Widgets::Assignees, Widgets::Labels, Widgets::Description, Widgets::Hierarchy, Widgets::StartAndDueDate, - Widgets::Milestone], - incident: [Widgets::Description, Widgets::Hierarchy], - test_case: [Widgets::Description], - requirement: [Widgets::Description], - task: [Widgets::Assignees, Widgets::Labels, Widgets::Description, Widgets::Hierarchy, Widgets::StartAndDueDate, - Widgets::Milestone], - objective: [Widgets::Assignees, Widgets::Labels, Widgets::Description, Widgets::Hierarchy, Widgets::Milestone], - key_result: [Widgets::Assignees, Widgets::Labels, Widgets::Description, Widgets::StartAndDueDate] + issue: [ + Widgets::Assignees, + Widgets::Labels, + Widgets::Description, + Widgets::Hierarchy, + Widgets::StartAndDueDate, + Widgets::Milestone, + Widgets::Notes + ], + incident: [ + Widgets::Description, + Widgets::Hierarchy, + Widgets::Notes + ], + test_case: [ + Widgets::Description, + Widgets::Notes + ], + requirement: [ + Widgets::Description, + Widgets::Notes + ], + task: [ + Widgets::Assignees, + Widgets::Labels, + Widgets::Description, + Widgets::Hierarchy, + Widgets::StartAndDueDate, + Widgets::Milestone, + Widgets::Notes + ], + objective: [ + Widgets::Assignees, + Widgets::Labels, + Widgets::Description, + Widgets::Hierarchy, + Widgets::Milestone, + Widgets::Notes + ], + key_result: [ + Widgets::Assignees, + Widgets::Labels, + Widgets::Description, + Widgets::Hierarchy, + Widgets::StartAndDueDate, + Widgets::Notes + ] }.freeze + # A list of types user can change between - both original and new + # type must be included in this list. This is needed for legacy issues + # where it's possible to switch between issue and incident. + CHANGEABLE_BASE_TYPES = %w[issue incident test_case].freeze + WI_TYPES_WITH_CREATED_HEADER = %w[issue incident].freeze cache_markdown_field :description, pipeline: :single_line @@ -66,6 +121,7 @@ module WorkItems return found_type if found_type Gitlab::DatabaseImporters::WorkItems::BaseTypeImporter.upsert_types + Gitlab::DatabaseImporters::WorkItems::HierarchyRestrictionsImporter.upsert_restrictions find_by(namespace_id: nil, base_type: type) end diff --git a/app/models/work_items/widgets/notes.rb b/app/models/work_items/widgets/notes.rb new file mode 100644 index 00000000000..bde94ea8f43 --- /dev/null +++ b/app/models/work_items/widgets/notes.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module WorkItems + module Widgets + class Notes < Base + delegate :notes, to: :work_item + delegate_missing_to :work_item + + def declarative_policy_delegate + work_item + end + end + end +end diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index f8e7a912896..1ce866bd910 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -19,6 +19,14 @@ class BasePolicy < DeclarativePolicy::Base with_options scope: :user, score: 0 condition(:deactivated) { @user&.deactivated? } + desc "User is bot" + with_options scope: :user, score: 0 + condition(:bot) { @user&.bot? } + + desc "User is alert bot" + with_options scope: :user, score: 0 + condition(:alert_bot) { @user&.alert_bot? } + desc "User is support bot" with_options scope: :user, score: 0 condition(:support_bot) { @user&.support_bot? } @@ -50,9 +58,6 @@ class BasePolicy < DeclarativePolicy::Base ::Gitlab::ExternalAuthorization.perform_check? end - with_options scope: :user, score: 0 - condition(:alert_bot) { @user&.alert_bot? } - rule { external_authorization_enabled & ~can?(:read_all_resources) }.policy do prevent :read_cross_project end diff --git a/app/policies/ci/freeze_period_policy.rb b/app/policies/ci/freeze_period_policy.rb index 60e53a7b2f9..9e2cca5e5a2 100644 --- a/app/policies/ci/freeze_period_policy.rb +++ b/app/policies/ci/freeze_period_policy.rb @@ -2,6 +2,6 @@ module Ci class FreezePeriodPolicy < BasePolicy - delegate { @subject.resource_parent } + delegate { @subject.project } end end diff --git a/app/policies/ci/pipeline_schedule_variable_policy.rb b/app/policies/ci/pipeline_schedule_variable_policy.rb new file mode 100644 index 00000000000..dbbf9221e77 --- /dev/null +++ b/app/policies/ci/pipeline_schedule_variable_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Ci + class PipelineScheduleVariablePolicy < BasePolicy + delegate :pipeline_schedule + end +end diff --git a/app/policies/commit_signatures/ssh_signature_policy.rb b/app/policies/commit_signatures/ssh_signature_policy.rb new file mode 100644 index 00000000000..34c8f123029 --- /dev/null +++ b/app/policies/commit_signatures/ssh_signature_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module CommitSignatures + class SshSignaturePolicy < BasePolicy + delegate { @subject.project } + end +end diff --git a/app/policies/concerns/readonly_abilities.rb b/app/policies/concerns/archived_abilities.rb index 300f17088b7..b4dfad599c7 100644 --- a/app/policies/concerns/readonly_abilities.rb +++ b/app/policies/concerns/archived_abilities.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true -module ReadonlyAbilities +module ArchivedAbilities extend ActiveSupport::Concern - READONLY_ABILITIES = %i[ + ARCHIVED_ABILITIES = %i[ admin_tag push_code push_to_delete_protected_branch @@ -16,7 +16,7 @@ module ReadonlyAbilities create_incident ].freeze - READONLY_FEATURES = %i[ + ARCHIVED_FEATURES = %i[ issue issue_board_list merge_request @@ -40,14 +40,14 @@ module ReadonlyAbilities ].freeze class_methods do - def readonly_abilities - READONLY_ABILITIES + def archived_abilities + ARCHIVED_ABILITIES end - def readonly_features - READONLY_FEATURES + def archived_features + ARCHIVED_FEATURES end end end -ReadonlyAbilities::ClassMethods.prepend_mod_with('ReadonlyAbilities::ClassMethods') +ArchivedAbilities::ClassMethods.prepend_mod_with('ArchivedAbilities::ClassMethods') diff --git a/app/policies/group_member_policy.rb b/app/policies/group_member_policy.rb index f61f758a8e8..78ab9fc750b 100644 --- a/app/policies/group_member_policy.rb +++ b/app/policies/group_member_policy.rb @@ -6,7 +6,7 @@ class GroupMemberPolicy < BasePolicy delegate :group with_scope :subject - condition(:last_owner) { @subject.group.member_last_owner?(@subject) || @subject.group.member_last_blocked_owner?(@subject) } + condition(:last_owner) { @subject.last_owner_of_the_group? } condition(:project_bot) { @subject.user&.project_bot? && @subject.group.member?(@subject.user) } desc "Membership is users' own" diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 806c57bab74..858c145de3f 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -83,8 +83,8 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy with_scope :subject condition(:crm_enabled, score: 0, scope: :subject) { @subject.crm_enabled? } - condition(:group_runner_registration_allowed, scope: :global) do - Gitlab::CurrentSettings.valid_runner_registrars.include?('group') + condition(:group_runner_registration_allowed, scope: :subject) do + Gitlab::CurrentSettings.valid_runner_registrars.include?('group') && @subject.runner_registration_enabled? end rule { can?(:read_group) & design_management_enabled }.policy do @@ -193,6 +193,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :admin_group_member enable :change_visibility_level + enable :read_usage_quotas enable :read_group_runners enable :admin_group_runners enable :register_group_runners diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb index 87db228a698..491eebe9daf 100644 --- a/app/policies/issue_policy.rb +++ b/app/policies/issue_policy.rb @@ -9,7 +9,7 @@ class IssuePolicy < IssuablePolicy desc "User can read confidential issues" condition(:can_read_confidential) do - @user && IssueCollection.new([@subject]).visible_to(@user).any? + @user && (@user.admin? || can?(:reporter_access) || assignee_or_author?) # rubocop:disable Cop/UserAdmin end desc "Project belongs to a group, crm is enabled and user can read contacts in the root group" @@ -27,6 +27,23 @@ class IssuePolicy < IssuablePolicy desc "Issue is persisted" condition(:persisted, scope: :subject) { @subject.persisted? } + # accessing notes requires the notes widget to be available for work items(or issue) + condition(:notes_widget_enabled, scope: :subject) do + @subject.work_item_type.widgets.include?(::WorkItems::Widgets::Notes) + end + + rule { ~notes_widget_enabled }.policy do + prevent :create_note + prevent :read_note + prevent :read_internal_note + prevent :set_note_created_at + prevent :mark_note_as_confidential + # these actions on notes are not available on issues/work items yet, + # but preventing any action on work item notes as long as there is no notes widget seems reasonable + prevent :resolve_note + prevent :reposition_note + end + rule { confidential & ~can_read_confidential }.policy do prevent(*create_read_update_admin_destroy(:issue)) prevent :read_issue_iid diff --git a/app/policies/merge_request_policy.rb b/app/policies/merge_request_policy.rb index bda327cb661..1759cf057e4 100644 --- a/app/policies/merge_request_policy.rb +++ b/app/policies/merge_request_policy.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class MergeRequestPolicy < IssuablePolicy + condition(:can_approve) { can_approve? } + rule { locked }.policy do prevent :reopen_merge_request end @@ -14,10 +16,14 @@ class MergeRequestPolicy < IssuablePolicy prevent :accept_merge_request end - rule { can?(:update_merge_request) & is_project_member }.policy do + rule { can_approve }.policy do enable :approve_merge_request end + rule { can?(:approve_merge_request) & bot }.policy do + enable :reset_merge_request_approvals + end + rule { ~anonymous & can?(:read_merge_request) }.policy do enable :create_todo enable :update_subscription @@ -32,6 +38,12 @@ class MergeRequestPolicy < IssuablePolicy rule { can?(:admin_merge_request) }.policy do enable :set_merge_request_metadata end + + private + + def can_approve? + can?(:update_merge_request) && is_project_member? + end end MergeRequestPolicy.prepend_mod_with('MergeRequestPolicy') diff --git a/app/policies/namespaces/user_namespace_policy.rb b/app/policies/namespaces/user_namespace_policy.rb index 89158578ac1..1deeae8241f 100644 --- a/app/policies/namespaces/user_namespace_policy.rb +++ b/app/policies/namespaces/user_namespace_policy.rb @@ -5,6 +5,7 @@ module Namespaces rule { anonymous }.prevent_all condition(:can_create_personal_project, scope: :user) { @user.can_create_project? } + condition(:bot_user_namespace) { @subject.bot_user_namespace? } condition(:owner) { @subject.owner == @user } rule { owner | admin }.policy do @@ -21,6 +22,8 @@ module Namespaces rule { ~can_create_personal_project }.prevent :create_projects + rule { bot_user_namespace }.prevent :create_projects + rule { (owner | admin) & can?(:create_projects) }.enable :transfer_projects end end diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb index 67b57595beb..9fd95bbe42d 100644 --- a/app/policies/note_policy.rb +++ b/app/policies/note_policy.rb @@ -20,12 +20,20 @@ class NotePolicy < BasePolicy condition(:confidential, scope: :subject) { @subject.confidential? } + # if noteable is a work item it needs to check the notes widget availability + condition(:notes_widget_enabled, scope: :subject) do + !@subject.noteable.respond_to?(:work_item_type) || + @subject.noteable.work_item_type.widgets.include?(::WorkItems::Widgets::Notes) + end + # Should be matched with IssuablePolicy#read_internal_note # and EpicPolicy#read_internal_note condition(:can_read_confidential) do access_level >= Gitlab::Access::REPORTER || admin? end + rule { ~notes_widget_enabled }.prevent_all + rule { ~editable }.prevent :admin_note # If user can't read the issue/MR/etc then they should not be allowed to do anything to their own notes diff --git a/app/policies/project_member_policy.rb b/app/policies/project_member_policy.rb index bcfc7c87d41..ace74dca448 100644 --- a/app/policies/project_member_policy.rb +++ b/app/policies/project_member_policy.rb @@ -5,7 +5,7 @@ class ProjectMemberPolicy < BasePolicy delegate { @subject.project } condition(:target_is_holder_of_the_personal_namespace, scope: :subject) do - @subject.project.personal_namespace_holder?(@subject.user) + @subject.holder_of_the_personal_namespace? end desc "Membership is users' own access request" diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index bfeb1a602ab..7f67e80e432 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -2,7 +2,7 @@ class ProjectPolicy < BasePolicy include CrudPolicyHelpers - include ReadonlyAbilities + include ArchivedAbilities desc "Project has public builds enabled" condition(:public_builds, scope: :subject, score: 0) { project.public_builds? } @@ -121,7 +121,7 @@ class ProjectPolicy < BasePolicy desc "If user is authenticated via CI job token then the target project should be in scope" condition(:project_allowed_for_job_token) do - !@user&.from_ci_job_token? || @user.ci_job_token_scope.includes?(project) + !@user&.from_ci_job_token? || @user.ci_job_token_scope.allows?(project) end with_scope :subject @@ -369,29 +369,12 @@ class ProjectPolicy < BasePolicy prevent(:metrics_dashboard) end - condition(:split_operations_visibility_permissions) do - ::Feature.enabled?(:split_operations_visibility_permissions, @subject) - end - - rule { ~split_operations_visibility_permissions & 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 { split_operations_visibility_permissions & environments_disabled }.policy do + rule { environments_disabled }.policy do prevent(*create_read_update_admin_destroy(:environment)) prevent(*create_read_update_admin_destroy(:deployment)) end - rule { split_operations_visibility_permissions & feature_flags_disabled }.policy do + rule { feature_flags_disabled }.policy do prevent(*create_read_update_admin_destroy(:feature_flag)) prevent(:admin_feature_flags_user_lists) prevent(:admin_feature_flags_client) @@ -401,13 +384,13 @@ class ProjectPolicy < BasePolicy prevent(*create_read_update_admin_destroy(:release)) end - rule { split_operations_visibility_permissions & monitor_disabled }.policy do + rule { monitor_disabled }.policy do prevent(:metrics_dashboard) prevent(*create_read_update_admin_destroy(:sentry_issue)) prevent(*create_read_update_admin_destroy(:alert_management_alert)) end - rule { split_operations_visibility_permissions & infrastructure_disabled }.policy do + rule { infrastructure_disabled }.policy do prevent(*create_read_update_admin_destroy(:terraform_state)) prevent(*create_read_update_admin_destroy(:cluster)) prevent(:read_pod_logs) @@ -552,15 +535,15 @@ class ProjectPolicy < BasePolicy rule { can?(:push_code) }.enable :admin_tag rule { archived }.policy do - prevent(*readonly_abilities) + prevent(*archived_abilities) - readonly_features.each do |feature| + archived_features.each do |feature| prevent(*create_update_admin(feature)) end end rule { archived & ~pending_delete }.policy do - readonly_features.each do |feature| + archived_features.each do |feature| prevent(:"destroy_#{feature}") end end diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb index 92dcfeed104..f25436c54be 100644 --- a/app/presenters/blob_presenter.rb +++ b/app/presenters/blob_presenter.rb @@ -98,7 +98,7 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated end def permalink_path - url_helpers.project_blob_path(project, File.join(project.repository.commit.sha, blob.path)) + url_helpers.project_blob_path(project, File.join(project.repository.commit(blob.commit_id).sha, blob.path)) end def environment_formatted_external_url diff --git a/app/presenters/ci/freeze_period_presenter.rb b/app/presenters/ci/freeze_period_presenter.rb new file mode 100644 index 00000000000..064197f34dd --- /dev/null +++ b/app/presenters/ci/freeze_period_presenter.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Ci + class FreezePeriodPresenter < Gitlab::View::Presenter::Delegated + presents ::Ci::FreezePeriod, as: :freeze_period + + def start_time + return freeze_period.time_start if freeze_period.active? + + freeze_period.next_time_start + end + end +end diff --git a/app/presenters/group_member_presenter.rb b/app/presenters/group_member_presenter.rb index 88facc3608d..18554df4bd9 100644 --- a/app/presenters/group_member_presenter.rb +++ b/app/presenters/group_member_presenter.rb @@ -3,6 +3,10 @@ class GroupMemberPresenter < MemberPresenter presents ::GroupMember + def last_owner? + member.last_owner_of_the_group? + end + private def admin_member_permission diff --git a/app/presenters/member_presenter.rb b/app/presenters/member_presenter.rb index 67d044dd01c..4cdaca3c39e 100644 --- a/app/presenters/member_presenter.rb +++ b/app/presenters/member_presenter.rb @@ -37,6 +37,10 @@ class MemberPresenter < Gitlab::View::Presenter::Delegated false end + def last_owner? + raise NotImplementedError + end + private def admin_member_permission diff --git a/app/presenters/packages/pypi/simple_package_versions_presenter.rb b/app/presenters/packages/pypi/simple_package_versions_presenter.rb index 0baa0714463..2bccaf4db72 100644 --- a/app/presenters/packages/pypi/simple_package_versions_presenter.rb +++ b/app/presenters/packages/pypi/simple_package_versions_presenter.rb @@ -13,7 +13,10 @@ module Packages def links refs = [] - available_packages.each_batch do |batch| + available_packages.each_batch do |relation| + batch = relation.preload_files + .preload_pypi_metadatum + batch.each do |package| package_files = package.installable_package_files diff --git a/app/presenters/project_member_presenter.rb b/app/presenters/project_member_presenter.rb index da24972775a..bb389b7a3ab 100644 --- a/app/presenters/project_member_presenter.rb +++ b/app/presenters/project_member_presenter.rb @@ -21,6 +21,12 @@ class ProjectMemberPresenter < MemberPresenter super end + def last_owner? + # all owners of a project in a group are removable. + # but in personal projects, the namespace holder is not removable. + member.holder_of_the_personal_namespace? + end + private def admin_member_permission diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 0be13197343..4d1a9b3f589 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -68,7 +68,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated user_view = current_user.project_view - if can?(current_user, :download_code, project) + if can?(current_user, :read_code, project) user_view elsif user_view == 'activity' 'activity' @@ -179,7 +179,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated return if releases_count < 1 AnchorData.new(true, - statistic_icon('rocket') + + statistic_icon('deployments') + n_('%{strong_start}%{release_count}%{strong_end} Release', '%{strong_start}%{release_count}%{strong_end} Releases', releases_count).html_safe % { release_count: number_with_delimiter(releases_count), strong_start: '<strong class="project-stat-value">'.html_safe, @@ -290,16 +290,10 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated 'btn-default', nil, 'license') - else - if can_current_user_push_to_default_branch? - AnchorData.new(false, + elsif can_current_user_push_to_default_branch? + AnchorData.new(false, content_tag(:span, statistic_icon + _('Add LICENSE'), class: 'add-license-link d-flex'), empty_repo? ? add_license_ide_path : add_license_path) - else - AnchorData.new(false, - icon + content_tag(:span, _('No license. All rights reserved'), class: 'project-stat-value'), - nil) - end end end @@ -423,7 +417,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end def anonymous_project_view - if !project.empty_repo? && can?(current_user, :download_code, project) + if !project.empty_repo? && can?(current_user, :read_code, project) 'files' elsif project.wiki_repository_exists? && can?(current_user, :read_wiki, project) 'wiki' diff --git a/app/presenters/search_service_presenter.rb b/app/presenters/search_service_presenter.rb index 4755b88cbea..d7d959217b0 100644 --- a/app/presenters/search_service_presenter.rb +++ b/app/presenters/search_service_presenter.rb @@ -25,7 +25,7 @@ class SearchServicePresenter < Gitlab::View::Presenter::Delegated case scope when 'users' - objects.eager_load(:status) if objects.respond_to?(:eager_load) # rubocop:disable CodeReuse/ActiveRecord + objects.respond_to?(:eager_load) ? objects.eager_load(:status) : objects # rubocop:disable CodeReuse/ActiveRecord when 'commits' prepare_commits_for_rendering(objects) else @@ -45,4 +45,10 @@ class SearchServicePresenter < Gitlab::View::Presenter::Delegated def without_count? search_objects.is_a?(Kaminari::PaginatableWithoutCount) end + + def advanced_search_enabled? + false + end end + +SearchServicePresenter.prepend_mod_with('SearchServicePresenter') diff --git a/app/serializers/analytics/cycle_analytics/configuration_entity.rb b/app/serializers/analytics/cycle_analytics/configuration_entity.rb index 45ea7c92758..6a9ec3f5e9e 100644 --- a/app/serializers/analytics/cycle_analytics/configuration_entity.rb +++ b/app/serializers/analytics/cycle_analytics/configuration_entity.rb @@ -11,11 +11,7 @@ module Analytics private def events - (stage_events.events - stage_events.internal_events).sort_by(&:name) - end - - def stage_events - Gitlab::Analytics::CycleAnalytics::StageEvents + Gitlab::Analytics::CycleAnalytics::StageEvents.selectable_events end end end diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb index dc7b5e95361..1caa9720c08 100644 --- a/app/serializers/build_details_entity.rb +++ b/app/serializers/build_details_entity.rb @@ -11,7 +11,7 @@ class BuildDetailsEntity < Ci::JobEntity expose :metadata, using: BuildMetadataEntity expose :pipeline, using: Ci::PipelineEntity - expose :deployment_status, if: -> (*) { build.starts_environment? } do + expose :deployment_status, if: -> (*) { build.deployment_job? } do expose :deployment_status, as: :status expose :persisted_environment, as: :environment do |build, options| options.merge(deployment_details: false).yield_self do |opts| diff --git a/app/serializers/ci/basic_variable_entity.rb b/app/serializers/ci/basic_variable_entity.rb index dad59e8735b..210c01408a6 100644 --- a/app/serializers/ci/basic_variable_entity.rb +++ b/app/serializers/ci/basic_variable_entity.rb @@ -9,5 +9,6 @@ module Ci expose :protected?, as: :protected expose :masked?, as: :masked + expose :raw?, as: :raw end end diff --git a/app/serializers/issuable_sidebar_basic_entity.rb b/app/serializers/issuable_sidebar_basic_entity.rb index b66aad6cc65..9039606a8e5 100644 --- a/app/serializers/issuable_sidebar_basic_entity.rb +++ b/app/serializers/issuable_sidebar_basic_entity.rb @@ -38,6 +38,10 @@ class IssuableSidebarBasicEntity < Grape::Entity expose :can_admin_label do |issuable| can?(current_user, :admin_label, issuable.project) end + + expose :can_create_timelogs do |issuable| + can?(current_user, :create_timelog, issuable) + end end expose :issuable_json_path do |issuable| diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index 3d94d2e2e9d..397f333008c 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -7,11 +7,15 @@ class IssueEntity < IssuableEntity item.try(:upcase) end + format_with(:iso8601) do |item| + item.try(:iso8601) + end + expose :state expose :milestone_id expose :updated_by_id - expose :created_at - expose :updated_at + expose :created_at, format_with: :iso8601 + expose :updated_at, format_with: :iso8601 expose :milestone, using: API::Entities::Milestone expose :labels, using: LabelEntity expose :lock_version @@ -85,6 +89,11 @@ class IssueEntity < IssuableEntity end expose :issue_email_participants do |issue| + # TODO - This is a Temporary solution to avoid leaking participants' emails + # on public/internal projects when issue is not confidential. + # Should be removed when https://gitlab.com/gitlab-org/gitlab/-/issues/383448 is implemented. + next [] unless issue.confidential? + issue.issue_email_participants.map { |x| { email: x.email } } end diff --git a/app/serializers/member_entity.rb b/app/serializers/member_entity.rb index bfb5b3eeae6..8e5d352e413 100644 --- a/app/serializers/member_entity.rb +++ b/app/serializers/member_entity.rb @@ -23,6 +23,8 @@ class MemberEntity < Grape::Entity member.can_remove? end + expose :last_owner?, as: :is_last_owner + expose :is_direct_member do |member, options| member.source == options[:source] end diff --git a/app/serializers/merge_request_metrics_entity.rb b/app/serializers/merge_request_metrics_entity.rb index 1c9db08d103..ded82a9ef45 100644 --- a/app/serializers/merge_request_metrics_entity.rb +++ b/app/serializers/merge_request_metrics_entity.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true class MergeRequestMetricsEntity < Grape::Entity - expose :latest_closed_at, as: :closed_at - expose :merged_at + format_with(:iso8601) do |item| + item.try(:iso8601) + end + + expose :latest_closed_at, as: :closed_at, format_with: :iso8601 + expose :merged_at, format_with: :iso8601 expose :latest_closed_by, as: :closed_by, using: UserEntity expose :merged_by, using: UserEntity end diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb index ab180b35b29..cef3f4555df 100644 --- a/app/serializers/merge_request_poll_widget_entity.rb +++ b/app/serializers/merge_request_poll_widget_entity.rb @@ -31,7 +31,7 @@ class MergeRequestPollWidgetEntity < Grape::Entity end expose :only_allow_merge_if_pipeline_succeeds do |merge_request| - merge_request.project.only_allow_merge_if_pipeline_succeeds? + merge_request.project.only_allow_merge_if_pipeline_succeeds?(inherit_group_setting: true) end # CI related diff --git a/app/serializers/project_entity.rb b/app/serializers/project_entity.rb index 77e2115fbe2..cbdc19a83ce 100644 --- a/app/serializers/project_entity.rb +++ b/app/serializers/project_entity.rb @@ -13,4 +13,8 @@ class ProjectEntity < Grape::Entity expose :full_name, documentation: { type: 'string', example: 'GitLab Org / GitLab' } do |project| project.full_name end + + expose :refs_url do |project| + refs_project_path(project) + end end diff --git a/app/services/admin/set_feature_flag_service.rb b/app/services/admin/set_feature_flag_service.rb index d72a18a6a58..3378be7eddd 100644 --- a/app/services/admin/set_feature_flag_service.rb +++ b/app/services/admin/set_feature_flag_service.rb @@ -2,82 +2,143 @@ module Admin class SetFeatureFlagService + UnknownOperationError = Class.new(StandardError) + def initialize(feature_flag_name:, params:) @name = feature_flag_name + @target = Feature::Target.new(params) @params = params + @force = params[:force] end def execute - unless params[:force] + unless force error = validate_feature_flag_name return ServiceResponse.error(message: error, reason: :invalid_feature_flag) if error end - flag_target = Feature::Target.new(params) - value = gate_value(params) - - case value - when true - enable!(flag_target) - when false - disable!(flag_target) + if target.gate_specified? + update_targets else - enable_partially!(value, params) + update_global end feature_flag = Feature.get(name) # rubocop:disable Gitlab/AvoidFeatureGet ServiceResponse.success(payload: { feature_flag: feature_flag }) - rescue Feature::Target::UnknowTargetError => e + rescue Feature::InvalidOperation => e + ServiceResponse.error(message: e.message, reason: :illegal_operation) + rescue UnknownOperationError => e + ServiceResponse.error(message: e.message, reason: :illegal_operation) + rescue Feature::Target::UnknownTargetError => e ServiceResponse.error(message: e.message, reason: :actor_not_found) end private - attr_reader :name, :params + attr_reader :name, :params, :target, :force - def enable!(flag_target) - if flag_target.gate_specified? - flag_target.targets.each { |target| Feature.enable(name, target) } - else - Feature.enable(name) + # Note: the if expressions in `update_targets` and `update_global` are order dependant. + def update_targets + target.targets.each do |target| + if enable? + enable(target) + elsif disable? + Feature.disable(name, target) + elsif opt_out? + Feature.opt_out(name, target) + elsif remove_opt_out? + remove_opt_out(target) + else + raise UnknownOperationError, "Cannot set '#{name}' to #{value.inspect} for #{target}" + end end end - def disable!(flag_target) - if flag_target.gate_specified? - flag_target.targets.each { |target| Feature.disable(name, target) } - else + def update_global + if enable? + Feature.enable(name) + elsif disable? Feature.disable(name) + elsif percentage_of_actors? + Feature.enable_percentage_of_actors(name, percentage) + elsif percentage_of_time? + Feature.enable_percentage_of_time(name, percentage) + else + msg = if key.present? + "Cannot set '#{name}' (#{key.inspect}) to #{value.inspect}" + else + "Cannot set '#{name}' to #{value.inspect}" + end + + raise UnknownOperationError, msg end end - def enable_partially!(value, params) - if params[:key] == 'percentage_of_actors' - Feature.enable_percentage_of_actors(name, value) - else - Feature.enable_percentage_of_time(name, value) + def remove_opt_out(target) + raise Feature::InvalidOperation, "No opt-out exists for #{target}" unless Feature.opted_out?(name, target) + + Feature.remove_opt_out(name, target) + end + + def enable(target) + if Feature.opted_out?(name, target) + target_name = target.respond_to?(:to_reference) ? target.to_reference : target.to_s + raise Feature::InvalidOperation, "Opt-out exists for #{target_name} - remove opt-out before enabling" end + + Feature.enable(name, target) end - def validate_feature_flag_name - # overridden in EE + def value + params[:value] end - def gate_value(params) - case params[:value] - when 'true' - true - when '0', 'false' - false - else - # https://github.com/jnunemaker/flipper/blob/master/lib/flipper/typecast.rb#L47 - if params[:value].to_s.include?('.') - params[:value].to_f - else - params[:value].to_i - end - end + def key + params[:key] + end + + def numeric_value? + params[:value].match?(/^\d+(\.\d+)?$/) + end + + def percentage + raise UnknownOperationError, "Not a percentage" unless numeric_value? + + value.to_f + end + + def percentage_of_actors? + key == 'percentage_of_actors' + end + + def percentage_of_time? + return true if key == 'percentage_of_time' + return numeric_value? if key.nil? + + false + end + + # Note: `key` is NOT considered - setting to a percentage to 0 is the same as disabling. + def disable? + value.in?(%w[0 0.0 false]) + end + + # Note: `key` is NOT considered - setting to a percentage to 100 is the same + def enable? + value.in?(%w[100 100.0 true]) + end + + def opt_out? + value == 'opt_out' + end + + def remove_opt_out? + value == 'remove_opt_out' + end + + def validate_feature_flag_name + ## Overridden in EE end end end diff --git a/app/services/bulk_imports/create_service.rb b/app/services/bulk_imports/create_service.rb index d3c6dcca588..124b5964232 100644 --- a/app/services/bulk_imports/create_service.rb +++ b/app/services/bulk_imports/create_service.rb @@ -57,11 +57,14 @@ module BulkImports bulk_import = BulkImport.create!( user: current_user, source_type: 'gitlab', - source_version: client.instance_version + source_version: client.instance_version, + source_enterprise: client.instance_enterprise ) bulk_import.create_configuration!(credentials.slice(:url, :access_token)) Array.wrap(params).each do |entity| + track_access_level(entity) + BulkImports::Entity.create!( bulk_import: bulk_import, source_type: entity[:source_type], @@ -75,6 +78,34 @@ module BulkImports end end + def track_access_level(entity) + Gitlab::Tracking.event( + self.class.name, + 'create', + label: 'import_access_level', + user: current_user, + extra: { user_role: user_role(entity[:destination_namespace]), import_type: 'bulk_import_group' } + ) + end + + def user_role(destination_namespace) + namespace = Namespace.find_by_full_path(destination_namespace) + # if there is no parent namespace we assume user will be group creator/owner + return owner_role unless destination_namespace + return owner_role unless namespace + return owner_role unless namespace.group_namespace? # user namespace + + membership = current_user.group_members.find_by(source_id: namespace.id) # rubocop:disable CodeReuse/ActiveRecord + + return 'Not a member' unless membership + + Gitlab::Access.human_access(membership.access_level) + end + + def owner_role + Gitlab::Access.human_access(Gitlab::Access::OWNER) + end + def client @client ||= BulkImports::Clients::HTTP.new( url: @credentials[:url], diff --git a/app/services/bulk_imports/file_download_service.rb b/app/services/bulk_imports/file_download_service.rb index 45f1350df92..ee499c782b4 100644 --- a/app/services/bulk_imports/file_download_service.rb +++ b/app/services/bulk_imports/file_download_service.rb @@ -31,14 +31,13 @@ module BulkImports @tmpdir = tmpdir @file_size_limit = file_size_limit @allowed_content_types = allowed_content_types + @remote_content_validated = false end def execute validate_tmpdir validate_filepath validate_url - validate_content_type - validate_content_length download_file @@ -49,7 +48,7 @@ module BulkImports private - attr_reader :configuration, :relative_url, :tmpdir, :file_size_limit, :allowed_content_types + attr_reader :configuration, :relative_url, :tmpdir, :file_size_limit, :allowed_content_types, :response_headers def download_file File.open(filepath, 'wb') do |file| @@ -58,6 +57,15 @@ module BulkImports http_client.stream(relative_url) do |chunk| next if bytes_downloaded == 0 && [301, 302, 303, 307, 308].include?(chunk.code) + @response_headers ||= Gitlab::HTTP::Response::Headers.new(chunk.http_response.to_hash) + + unless @remote_content_validated + validate_content_type + validate_content_length + + @remote_content_validated = true + end + bytes_downloaded += chunk.size validate_size!(bytes_downloaded) @@ -90,10 +98,6 @@ module BulkImports ::Gitlab::CurrentSettings.allow_local_requests_from_web_hooks_and_services? end - def response_headers - @response_headers ||= http_client.head(relative_url).headers - end - def validate_tmpdir Gitlab::Utils.check_allowed_absolute_path!(tmpdir, [Dir.tmpdir]) end diff --git a/app/services/chat_names/find_user_service.rb b/app/services/chat_names/find_user_service.rb index 3dd3ba7f01c..3b204d51bab 100644 --- a/app/services/chat_names/find_user_service.rb +++ b/app/services/chat_names/find_user_service.rb @@ -2,9 +2,9 @@ module ChatNames class FindUserService - def initialize(integration, params) - @integration = integration - @params = params + def initialize(team_id, user_id) + @team_id = team_id + @user_id = user_id end def execute @@ -17,12 +17,13 @@ module ChatNames private + attr_reader :team_id, :user_id + # rubocop: disable CodeReuse/ActiveRecord def find_chat_name ChatName.find_by( - integration: @integration, - team_id: @params[:team_id], - chat_id: @params[:user_id] + team_id: team_id, + chat_id: user_id ) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/ci/append_build_trace_service.rb b/app/services/ci/append_build_trace_service.rb index 0eef0ff0e61..4432eecc24a 100644 --- a/app/services/ci/append_build_trace_service.rb +++ b/app/services/ci/append_build_trace_service.rb @@ -24,6 +24,11 @@ module Ci body_start = content_range[0].to_i body_end = body_start + body_data.bytesize + if first_debug_chunk?(body_start) + # Update the build metadata prior to appending trace content + build.enable_debug_trace! + end + if trace_size_exceeded?(body_end) build.drop(:trace_size_exceeded) @@ -45,10 +50,18 @@ module Ci delegate :project, to: :build + def first_debug_chunk?(body_start) + body_start == 0 && debug_trace + end + def stream_range params.fetch(:content_range) end + def debug_trace + params.fetch(:debug_trace, false) + end + def log_range_error(stream_size, body_end) extra = { build_id: build.id, diff --git a/app/services/ci/create_downstream_pipeline_service.rb b/app/services/ci/create_downstream_pipeline_service.rb index 25cc9045052..3d0a7fb99ea 100644 --- a/app/services/ci/create_downstream_pipeline_service.rb +++ b/app/services/ci/create_downstream_pipeline_service.rb @@ -11,24 +11,25 @@ module Ci DuplicateDownstreamPipelineError = Class.new(StandardError) MAX_NESTED_CHILDREN = 2 - MAX_HIERARCHY_SIZE = 1000 def execute(bridge) @bridge = bridge - if bridge.has_downstream_pipeline? + if @bridge.has_downstream_pipeline? Gitlab::ErrorTracking.track_exception( DuplicateDownstreamPipelineError.new, bridge_id: @bridge.id, project_id: @bridge.project_id ) - return error('Already has a downstream pipeline') + return ServiceResponse.error(message: 'Already has a downstream pipeline') end pipeline_params = @bridge.downstream_pipeline_params target_ref = pipeline_params.dig(:target_revision, :ref) - return error('Pre-conditions not met') unless ensure_preconditions!(target_ref) + return ServiceResponse.error(message: 'Pre-conditions not met') unless ensure_preconditions!(target_ref) + + return ServiceResponse.error(message: 'Can not run the bridge') unless @bridge.run service = ::Ci::CreatePipelineService.new( pipeline_params.fetch(:project), @@ -40,10 +41,7 @@ module Ci .payload log_downstream_pipeline_creation(downstream_pipeline) - - downstream_pipeline.tap do |pipeline| - update_bridge_status!(@bridge, pipeline) - end + update_bridge_status!(@bridge, downstream_pipeline) end private @@ -54,9 +52,12 @@ module Ci # If bridge uses `strategy:depend` we leave it running # and update the status when the downstream pipeline completes. subject.success! unless subject.dependent? + ServiceResponse.success(payload: pipeline) else - subject.options[:downstream_errors] = pipeline.errors.full_messages + message = pipeline.errors.full_messages + subject.options[:downstream_errors] = message subject.drop!(:downstream_pipeline_creation_failed) + ServiceResponse.error(payload: pipeline, message: message) end end rescue StateMachines::InvalidTransition => e @@ -64,6 +65,7 @@ module Ci Ci::Bridge::InvalidTransitionError.new(e.message), bridge_id: bridge.id, downstream_pipeline_id: pipeline.id) + ServiceResponse.error(payload: pipeline, message: e.message) end def ensure_preconditions!(target_ref) @@ -151,7 +153,13 @@ module Ci return false unless @bridge.triggers_downstream_pipeline? # Applies to the entire pipeline tree across all projects - @bridge.pipeline.complete_hierarchy_count >= MAX_HIERARCHY_SIZE + # A pipeline tree can be shared between multiple namespaces (customers), the limit that is used here + # is the limit of the namespace that has added a downstream pipeline to a pipeline tree. + @bridge.project.actual_limits.exceeded?(:pipeline_hierarchy_size, complete_hierarchy_count) + end + + def complete_hierarchy_count + @bridge.pipeline.complete_hierarchy_count end def config_checksum(pipeline) diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 4106dfe0ecc..9c3cc803587 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -4,8 +4,6 @@ module Ci class CreatePipelineService < BaseService attr_reader :pipeline, :logger - CreateError = Class.new(StandardError) - LOG_MAX_DURATION_THRESHOLD = 3.seconds LOG_MAX_PIPELINE_SIZE = 2_000 LOG_MAX_CREATION_THRESHOLD = 20.seconds @@ -140,25 +138,24 @@ module Ci def build_logger Gitlab::Ci::Pipeline::Logger.new(project: project) do |l| l.log_when do |observations| - observations.any? do |name, values| - values.any? && + observations.any? do |name, observation| name.to_s.end_with?('duration_s') && - values.max >= LOG_MAX_DURATION_THRESHOLD + Array(observation).max >= LOG_MAX_DURATION_THRESHOLD end end l.log_when do |observations| - values = observations['pipeline_size_count'] - next false if values.empty? + count = observations['pipeline_size_count'] + next false unless count - values.max >= LOG_MAX_PIPELINE_SIZE + count >= LOG_MAX_PIPELINE_SIZE end l.log_when do |observations| - values = observations['pipeline_creation_duration_s'] - next false if values.empty? + duration = observations['pipeline_creation_duration_s'] + next false unless duration - values.max >= LOG_MAX_CREATION_THRESHOLD + duration >= LOG_MAX_CREATION_THRESHOLD end end end diff --git a/app/services/ci/enqueue_job_service.rb b/app/services/ci/enqueue_job_service.rb new file mode 100644 index 00000000000..9e3bea3fd28 --- /dev/null +++ b/app/services/ci/enqueue_job_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Ci + class EnqueueJobService + attr_accessor :job, :current_user, :variables + + def initialize(job, current_user:, variables: nil) + @job = job + @current_user = current_user + @variables = variables + end + + def execute(&transition) + job.user = current_user + job.job_variables_attributes = variables if variables + + transition ||= ->(job) { job.enqueue! } + Gitlab::OptimisticLocking.retry_lock(job, name: 'ci_enqueue_job', &transition) + + ResetSkippedJobsService.new(job.project, current_user).execute(job) + + job + end + end +end diff --git a/app/services/ci/generate_kubeconfig_service.rb b/app/services/ci/generate_kubeconfig_service.rb index 347bc99dbf5..1c6aaa9d1ff 100644 --- a/app/services/ci/generate_kubeconfig_service.rb +++ b/app/services/ci/generate_kubeconfig_service.rb @@ -2,9 +2,11 @@ module Ci class GenerateKubeconfigService - def initialize(pipeline, token:) + def initialize(pipeline, token:, environment:) @pipeline = pipeline @token = token + @environment = environment + @template = Gitlab::Kubernetes::Kubeconfig::Template.new end @@ -36,10 +38,13 @@ module Ci private - attr_reader :pipeline, :token, :template + attr_reader :pipeline, :token, :environment, :template def agent_authorizations - pipeline.cluster_agent_authorizations + ::Clusters::Agents::FilterAuthorizationsService.new( + pipeline.cluster_agent_authorizations, + environment: environment + ).execute end def cluster_name diff --git a/app/services/ci/job_artifacts/create_service.rb b/app/services/ci/job_artifacts/create_service.rb index 3dc097a8603..ee9982cf3ab 100644 --- a/app/services/ci/job_artifacts/create_service.rb +++ b/app/services/ci/job_artifacts/create_service.rb @@ -16,7 +16,7 @@ module Ci def initialize(job) @job = job @project = job.project - @pipeline = job.pipeline if ::Feature.enabled?(:ci_update_unlocked_job_artifacts, @project) + @pipeline = job.pipeline end def authorize(artifact_type:, filesize: nil) @@ -85,7 +85,7 @@ module Ci expire_in: expire_in } - artifact_attributes[:locked] = pipeline.locked if ::Feature.enabled?(:ci_update_unlocked_job_artifacts, project) + artifact_attributes[:locked] = pipeline.locked artifact = Ci::JobArtifact.new( artifact_attributes.merge( diff --git a/app/services/ci/pipeline_schedule_service.rb b/app/services/ci/pipeline_schedule_service.rb index 536eaa56f9b..d320382d19f 100644 --- a/app/services/ci/pipeline_schedule_service.rb +++ b/app/services/ci/pipeline_schedule_service.rb @@ -8,7 +8,7 @@ module Ci # Ensure `next_run_at` is set properly before creating a pipeline. # Otherwise, multiple pipelines could be created in a short interval. schedule.schedule_next_run! - RunPipelineScheduleWorker.perform_async(schedule.id, schedule.owner&.id) + RunPipelineScheduleWorker.perform_async(schedule.id, current_user&.id) end end end diff --git a/app/services/ci/pipeline_schedules/calculate_next_run_service.rb b/app/services/ci/pipeline_schedules/calculate_next_run_service.rb index 6c8ccb017e9..a1b9ab5f82e 100644 --- a/app/services/ci/pipeline_schedules/calculate_next_run_service.rb +++ b/app/services/ci/pipeline_schedules/calculate_next_run_service.rb @@ -35,7 +35,7 @@ module Ci def worker_cron strong_memoize(:worker_cron) do - Gitlab::Ci::CronParser.new(worker_cron_expression, Time.zone.name) + Gitlab::Ci::CronParser.new(@schedule.worker_cron_expression, Time.zone.name) end end @@ -50,10 +50,6 @@ module Ci Gitlab::Ci::CronParser.parse_natural("every #{every_x_minutes} minutes", Time.zone.name) end end - - def worker_cron_expression - Settings.cron_jobs['pipeline_schedule_worker']['cron'] - end end end end diff --git a/app/services/ci/play_bridge_service.rb b/app/services/ci/play_bridge_service.rb index a719467253e..ffcd2b05b31 100644 --- a/app/services/ci/play_bridge_service.rb +++ b/app/services/ci/play_bridge_service.rb @@ -5,12 +5,7 @@ module Ci def execute(bridge) check_access!(bridge) - bridge.tap do |bridge| - bridge.user = current_user - bridge.enqueue! - - AfterRequeueJobService.new(project, current_user).execute(bridge) - end + Ci::EnqueueJobService.new(bridge, current_user: current_user).execute end private diff --git a/app/services/ci/play_build_service.rb b/app/services/ci/play_build_service.rb index b7aec57f3e3..8d2225aba71 100644 --- a/app/services/ci/play_build_service.rb +++ b/app/services/ci/play_build_service.rb @@ -5,17 +5,7 @@ module Ci def execute(build, job_variables_attributes = nil) check_access!(build, job_variables_attributes) - if build.can_enqueue? - build.user = current_user - build.job_variables_attributes = job_variables_attributes || [] - build.enqueue! - - AfterRequeueJobService.new(project, current_user).execute(build) - - build - else - retry_build(build) - end + Ci::EnqueueJobService.new(build, current_user: current_user, variables: job_variables_attributes || []).execute rescue StateMachines::InvalidTransition retry_build(build.reset) end diff --git a/app/services/ci/process_build_service.rb b/app/services/ci/process_build_service.rb index cb51d918fc2..a5300cfd29f 100644 --- a/app/services/ci/process_build_service.rb +++ b/app/services/ci/process_build_service.rb @@ -15,7 +15,7 @@ module Ci private def process(build) - return enqueue(build) if Feature.enabled?(:ci_retry_job_fix, project) && build.enqueue_immediately? + return enqueue(build) if build.enqueue_immediately? if build.schedulable? build.schedule diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index f11577feb88..cd879e9bc07 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -108,15 +108,13 @@ module Ci def each_build(params, &blk) queue = ::Ci::Queue::BuildQueueService.new(runner) - builds = begin - if runner.instance_type? - queue.builds_for_shared_runner - elsif runner.group_type? - queue.builds_for_group_runner - else - queue.builds_for_project_runner - end - end + builds = if runner.instance_type? + queue.builds_for_shared_runner + elsif runner.group_type? + queue.builds_for_group_runner + else + queue.builds_for_project_runner + end if runner.ref_protected? builds = queue.builds_for_protected_runner(builds) diff --git a/app/services/ci/after_requeue_job_service.rb b/app/services/ci/reset_skipped_jobs_service.rb index 4374ccd52e0..eb809b0162c 100644 --- a/app/services/ci/after_requeue_job_service.rb +++ b/app/services/ci/reset_skipped_jobs_service.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true module Ci - class AfterRequeueJobService < ::BaseService + # This service resets skipped jobs so they can be processed again. + # It affects the jobs that depend on the passed in job parameter. + class ResetSkippedJobsService < ::BaseService def execute(processable) @processable = processable diff --git a/app/services/ci/retry_job_service.rb b/app/services/ci/retry_job_service.rb index 74ebaef48b1..da0e80dfed7 100644 --- a/app/services/ci/retry_job_service.rb +++ b/app/services/ci/retry_job_service.rb @@ -28,7 +28,7 @@ module Ci check_access!(job) new_job = job.clone(current_user: current_user, new_job_variables_attributes: variables) - if Feature.enabled?(:ci_retry_job_fix, project) && enqueue_if_actionable && new_job.action? + if enqueue_if_actionable && new_job.action? new_job.set_enqueue_immediately! end @@ -64,15 +64,10 @@ module Ci next if new_job.failed? - Gitlab::OptimisticLocking.retry_lock(new_job, name: 'retry_build', &:enqueue) if Feature.disabled?( - :ci_retry_job_fix, project) + ResetSkippedJobsService.new(project, current_user).execute(job) - AfterRequeueJobService.new(project, current_user).execute(job) - - if Feature.enabled?(:ci_retry_job_fix, project) - Ci::PipelineCreation::StartPipelineService.new(job.pipeline).execute - new_job.reset - end + Ci::PipelineCreation::StartPipelineService.new(job.pipeline).execute + new_job.reset end end diff --git a/app/services/ci/test_failure_history_service.rb b/app/services/ci/test_failure_history_service.rb index 5a8072b2a0d..4f794b3d671 100644 --- a/app/services/ci/test_failure_history_service.rb +++ b/app/services/ci/test_failure_history_service.rb @@ -103,7 +103,8 @@ module Ci { unit_test_id: ci_unit_test.id, build_id: build.id, - failed_at: build.finished_at + failed_at: build.finished_at, + partition_id: build.partition_id } end end diff --git a/app/services/ci/track_failed_build_service.rb b/app/services/ci/track_failed_build_service.rb index caf7034234c..973c43a9445 100644 --- a/app/services/ci/track_failed_build_service.rb +++ b/app/services/ci/track_failed_build_service.rb @@ -6,7 +6,7 @@ # @param exit_code [Int] the resulting exit code. module Ci class TrackFailedBuildService - SCHEMA_URL = 'iglu:com.gitlab/ci_build_failed/jsonschema/1-0-0' + SCHEMA_URL = 'iglu:com.gitlab/ci_build_failed/jsonschema/1-0-1' def initialize(build:, exit_code:, failure_reason:) @build = build @@ -42,7 +42,8 @@ module Ci build_name: @build.name, build_artifact_types: @build.job_artifact_types, exit_code: @exit_code, - failure_reason: @failure_reason + failure_reason: @failure_reason, + project: @build.project_id } end end diff --git a/app/services/ci/unlock_artifacts_service.rb b/app/services/ci/unlock_artifacts_service.rb index 574cdae6480..237f1997edb 100644 --- a/app/services/ci/unlock_artifacts_service.rb +++ b/app/services/ci/unlock_artifacts_service.rb @@ -11,42 +11,21 @@ module Ci unlocked_pipeline_artifacts: 0 } - if ::Feature.enabled?(:ci_update_unlocked_job_artifacts, ci_ref.project) - loop do - unlocked_pipelines = [] - unlocked_job_artifacts = [] + loop do + unlocked_pipelines = [] + unlocked_job_artifacts = [] - ::Ci::Pipeline.transaction do - unlocked_pipelines = unlock_pipelines(ci_ref, before_pipeline) - unlocked_job_artifacts = unlock_job_artifacts(unlocked_pipelines) + ::Ci::Pipeline.transaction do + unlocked_pipelines = unlock_pipelines(ci_ref, before_pipeline) + unlocked_job_artifacts = unlock_job_artifacts(unlocked_pipelines) - results[:unlocked_pipeline_artifacts] += unlock_pipeline_artifacts(unlocked_pipelines) - end + results[:unlocked_pipeline_artifacts] += unlock_pipeline_artifacts(unlocked_pipelines) + end - break if unlocked_pipelines.empty? + break if unlocked_pipelines.empty? - results[:unlocked_pipelines] += unlocked_pipelines.length - results[:unlocked_job_artifacts] += unlocked_job_artifacts.length - end - else - query = <<~SQL.squish - UPDATE "ci_pipelines" - SET "locked" = #{::Ci::Pipeline.lockeds[:unlocked]} - WHERE "ci_pipelines"."id" in ( - #{collect_pipelines(ci_ref, before_pipeline).select(:id).to_sql} - LIMIT #{BATCH_SIZE} - FOR UPDATE SKIP LOCKED - ) - RETURNING "ci_pipelines"."id"; - SQL - - loop do - unlocked_pipelines = Ci::Pipeline.connection.exec_query(query) - - break if unlocked_pipelines.empty? - - results[:unlocked_pipelines] += unlocked_pipelines.length - end + results[:unlocked_pipelines] += unlocked_pipelines.length + results[:unlocked_job_artifacts] += unlocked_job_artifacts.length end results @@ -88,13 +67,6 @@ module Ci private - def collect_pipelines(ci_ref, before_pipeline) - pipeline_scope = ci_ref.pipelines - pipeline_scope = pipeline_scope.before_pipeline(before_pipeline) if before_pipeline - - pipeline_scope.artifacts_locked - end - def unlock_job_artifacts(pipelines) return if pipelines.empty? diff --git a/app/services/clusters/agents/filter_authorizations_service.rb b/app/services/clusters/agents/filter_authorizations_service.rb new file mode 100644 index 00000000000..68517ceec04 --- /dev/null +++ b/app/services/clusters/agents/filter_authorizations_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Clusters + module Agents + class FilterAuthorizationsService + def initialize(authorizations, filter_params) + @authorizations = authorizations + @filter_params = filter_params + + @environments_matcher = {} + end + + def execute + filter_by_environment(authorizations) + end + + private + + attr_reader :authorizations, :filter_params + + def filter_by_environment(auths) + return auths unless filter_by_environment? + + auths.select do |auth| + next true if auth.config['environments'].blank? + + auth.config['environments'].any? { |environment_pattern| matches_environment?(environment_pattern) } + end + end + + def filter_by_environment? + filter_params.has_key?(:environment) + end + + def environment_filter + @environment_filter ||= filter_params[:environment] + end + + def matches_environment?(environment_pattern) + return false if environment_filter.nil? + + environments_matcher(environment_pattern).match?(environment_filter) + end + + def environments_matcher(environment_pattern) + @environments_matcher[environment_pattern] ||= ::Gitlab::Ci::EnvironmentMatcher.new(environment_pattern) + end + end + end +end diff --git a/app/services/clusters/agents/refresh_authorization_service.rb b/app/services/clusters/agents/refresh_authorization_service.rb index 54b90a7304c..53b14ab54da 100644 --- a/app/services/clusters/agents/refresh_authorization_service.rb +++ b/app/services/clusters/agents/refresh_authorization_service.rb @@ -83,11 +83,7 @@ module Clusters end def allowed_projects - if group_root_ancestor? - root_ancestor.all_projects - else - ::Project.id_in(project.id) - end + root_ancestor.all_projects end def allowed_groups diff --git a/app/services/clusters/applications/base_service.rb b/app/services/clusters/applications/base_service.rb deleted file mode 100644 index c6f22cfa04c..00000000000 --- a/app/services/clusters/applications/base_service.rb +++ /dev/null @@ -1,96 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - class BaseService - InvalidApplicationError = Class.new(StandardError) - - attr_reader :cluster, :current_user, :params - - def initialize(cluster, user, params = {}) - @cluster = cluster - @current_user = user - @params = params.dup - end - - def execute(request) - instantiate_application.tap do |application| - if application.has_attribute?(:hostname) - application.hostname = params[:hostname] - end - - if application.has_attribute?(:email) - application.email = params[:email] - end - - if application.has_attribute?(:stack) - application.stack = params[:stack] - end - - if application.respond_to?(:oauth_application) - application.oauth_application = create_oauth_application(application, request) - end - - if application.instance_of?(Knative) - Serverless::AssociateDomainService - .new(application, pages_domain_id: params[:pages_domain_id], creator: current_user) - .execute - end - - worker = worker_class(application) - - application.make_scheduled! - - worker.perform_async(application.name, application.id) - end - end - - protected - - def worker_class(application) - raise NotImplementedError - end - - def builder - raise NotImplementedError - end - - def project_builders - raise NotImplementedError - end - - def instantiate_application - raise_invalid_application_error if unknown_application? - - builder || raise(InvalidApplicationError, "invalid application: #{application_name}") - end - - def raise_invalid_application_error - raise(InvalidApplicationError, "invalid application: #{application_name}") - end - - def unknown_application? - Clusters::Cluster::APPLICATIONS.keys.exclude?(application_name) - end - - def application_name - params[:application] - end - - def application_class - Clusters::Cluster::APPLICATIONS[application_name] - end - - def create_oauth_application(application, request) - oauth_application_params = { - name: params[:application], - redirect_uri: application.callback_url, - scopes: application.oauth_scopes, - owner: current_user - } - - ::Applications::CreateService.new(current_user, oauth_application_params).execute(request) - end - end - end -end diff --git a/app/services/clusters/applications/check_progress_service.rb b/app/services/clusters/applications/check_progress_service.rb deleted file mode 100644 index 4a07b955f8e..00000000000 --- a/app/services/clusters/applications/check_progress_service.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - class CheckProgressService < BaseHelmService - def execute - return unless operation_in_progress? - - case pod_phase - when Gitlab::Kubernetes::Pod::SUCCEEDED - on_success - when Gitlab::Kubernetes::Pod::FAILED - on_failed - else - check_timeout - end - rescue Kubeclient::HttpError => e - log_error(e) - - app.make_errored!(_('Kubernetes error: %{error_code}') % { error_code: e.error_code }) - end - - private - - def operation_in_progress? - raise NotImplementedError - end - - def on_success - raise NotImplementedError - end - - def pod_name - raise NotImplementedError - end - - def on_failed - app.make_errored!(_('Operation failed. Check pod logs for %{pod_name} for more details.') % { pod_name: pod_name }) - end - - def timed_out? - raise NotImplementedError - end - - def pod_phase - helm_api.status(pod_name) - end - end - end -end diff --git a/app/services/clusters/applications/install_service.rb b/app/services/clusters/applications/install_service.rb deleted file mode 100644 index dffb4ce65ab..00000000000 --- a/app/services/clusters/applications/install_service.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - class InstallService < BaseHelmService - def execute - return unless app.scheduled? - - app.make_installing! - - install - end - - private - - def install - log_event(:begin_install) - helm_api.install(install_command) - - log_event(:schedule_wait_for_installation) - ClusterWaitForAppInstallationWorker.perform_in( - ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) - rescue Kubeclient::HttpError => e - log_error(e) - app.make_errored!(_('Kubernetes error: %{error_code}') % { error_code: e.error_code }) - rescue StandardError => e - log_error(e) - app.make_errored!(_('Failed to install.')) - end - end - end -end diff --git a/app/services/clusters/applications/prometheus_config_service.rb b/app/services/clusters/applications/prometheus_config_service.rb deleted file mode 100644 index d39d63c874f..00000000000 --- a/app/services/clusters/applications/prometheus_config_service.rb +++ /dev/null @@ -1,155 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - class PrometheusConfigService - def initialize(project, cluster, app) - @project = project - @cluster = cluster - @app = app - end - - def execute(config = {}) - if has_alerts? - generate_alert_manager(config) - else - reset_alert_manager(config) - end - end - - private - - attr_reader :project, :cluster, :app - - def reset_alert_manager(config) - config = set_alert_manager_enabled(config, false) - config.delete('alertmanagerFiles') - config['serverFiles'] ||= {} - config['serverFiles']['alerts'] = {} - - config - end - - def generate_alert_manager(config) - config = set_alert_manager_enabled(config, true) - config = set_alert_manager_files(config) - - set_alert_manager_groups(config) - end - - def set_alert_manager_enabled(config, enabled) - config['alertmanager'] ||= {} - config['alertmanager']['enabled'] = enabled - - config - end - - def set_alert_manager_files(config) - config['alertmanagerFiles'] = { - 'alertmanager.yml' => { - 'receivers' => alert_manager_receivers_params, - 'route' => alert_manager_route_params - } - } - - config - end - - def set_alert_manager_groups(config) - config['serverFiles'] ||= {} - config['serverFiles']['alerts'] ||= {} - config['serverFiles']['alerts']['groups'] ||= [] - - environments_with_alerts.each do |env_name, alerts| - index = config['serverFiles']['alerts']['groups'].find_index do |group| - group['name'] == env_name - end - - if index - config['serverFiles']['alerts']['groups'][index]['rules'] = alerts - else - config['serverFiles']['alerts']['groups'] << { - 'name' => env_name, - 'rules' => alerts - } - end - end - - config - end - - def alert_manager_receivers_params - [ - { - 'name' => 'gitlab', - 'webhook_configs' => [ - { - 'url' => notify_url, - 'send_resolved' => true, - 'http_config' => { - 'bearer_token' => alert_manager_token - } - } - ] - } - ] - end - - def alert_manager_token - app.alert_manager_token - end - - def alert_manager_route_params - { - 'receiver' => 'gitlab', - 'group_wait' => '30s', - 'group_interval' => '5m', - 'repeat_interval' => '4h' - } - end - - def notify_url - ::Gitlab::Routing.url_helpers - .notify_project_prometheus_alerts_url(project, format: :json) - end - - def has_alerts? - environments_with_alerts.values.flatten(1).any? - end - - def environments_with_alerts - @environments_with_alerts ||= - environments.each_with_object({}) do |environment, hash| - name = rule_name(environment) - hash[name] = alerts(environment) - end - end - - def rule_name(environment) - "#{environment.name}.rules" - end - - def alerts(environment) - alerts = Projects::Prometheus::AlertsFinder - .new(environment: environment) - .execute - - alerts.map do |alert| - hash = alert.to_param - hash['expr'] = substitute_query_variables(hash['expr'], environment) - hash - end - end - - def substitute_query_variables(query, environment) - result = ::Prometheus::ProxyVariableSubstitutionService.new(environment, query: query).execute - - result[:params][:query] - end - - def environments - project.environments_for_scope(cluster.environment_scope) - end - end - end -end diff --git a/app/services/clusters/applications/upgrade_service.rb b/app/services/clusters/applications/upgrade_service.rb deleted file mode 100644 index ac68e64af38..00000000000 --- a/app/services/clusters/applications/upgrade_service.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - class UpgradeService < BaseHelmService - def execute - return unless app.scheduled? - - app.make_updating! - - upgrade - end - - private - - def upgrade - # install_command works with upgrades too - # as it basically does `helm upgrade --install` - log_event(:begin_upgrade) - helm_api.update(install_command) - - log_event(:schedule_wait_for_upgrade) - ClusterWaitForAppInstallationWorker.perform_in( - ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) - rescue Kubeclient::HttpError => e - log_error(e) - app.make_errored!(_('Kubernetes error: %{error_code}') % { error_code: e.error_code }) - rescue StandardError => e - log_error(e) - app.make_errored!(_('Failed to upgrade.')) - end - end - end -end diff --git a/app/services/clusters/kubernetes/create_or_update_service_account_service.rb b/app/services/clusters/kubernetes/create_or_update_service_account_service.rb index eabc428d0d2..e87640f4c76 100644 --- a/app/services/clusters/kubernetes/create_or_update_service_account_service.rb +++ b/app/services/clusters/kubernetes/create_or_update_service_account_service.rb @@ -3,7 +3,7 @@ module Clusters module Kubernetes class CreateOrUpdateServiceAccountService - def initialize(kubeclient, service_account_name:, service_account_namespace:, service_account_namespace_labels: nil, token_name:, rbac:, namespace_creator: false, role_binding_name: nil) + def initialize(kubeclient, service_account_name:, service_account_namespace:, token_name:, rbac:, service_account_namespace_labels: nil, namespace_creator: false, role_binding_name: nil) @kubeclient = kubeclient @service_account_name = service_account_name @service_account_namespace = service_account_namespace diff --git a/app/services/concerns/incident_management/usage_data.rb b/app/services/concerns/incident_management/usage_data.rb index 27e60029ea3..40183085344 100644 --- a/app/services/concerns/incident_management/usage_data.rb +++ b/app/services/concerns/incident_management/usage_data.rb @@ -7,7 +7,23 @@ module IncidentManagement def track_incident_action(current_user, target, action) return unless target.incident? - track_usage_event(:"incident_management_#{action}", current_user.id) + event = "incident_management_#{action}" + track_usage_event(event, current_user.id) + + namespace = target.try(:namespace) + project = target.try(:project) + + return unless Feature.enabled?(:route_hll_to_snowplow_phase2, target.try(:namespace)) + + Gitlab::Tracking.event( + self.class.to_s, + event, + project: project, + namespace: namespace, + user: current_user, + label: 'redis_hll_counters.incident_management.incident_management_total_unique_counts_monthly', + context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: event).to_context] + ) end end end diff --git a/app/services/concerns/rate_limited_service.rb b/app/services/concerns/rate_limited_service.rb index 5d7247a5b99..fa366c1ccd0 100644 --- a/app/services/concerns/rate_limited_service.rb +++ b/app/services/concerns/rate_limited_service.rb @@ -49,8 +49,8 @@ module RateLimitedService end def evaluated_scope_for(service) - opts[:scope].each_with_object({}) do |var, all| - all[var] = service.public_send(var) # rubocop: disable GitlabSecurity/PublicSend + opts[:scope].index_with do |var| + service.public_send(var) # rubocop: disable GitlabSecurity/PublicSend end end end diff --git a/app/services/deployments/create_for_build_service.rb b/app/services/deployments/create_for_build_service.rb index 7bc0ea88910..b58aa50a66f 100644 --- a/app/services/deployments/create_for_build_service.rb +++ b/app/services/deployments/create_for_build_service.rb @@ -28,7 +28,7 @@ module Deployments def to_resource(build, environment) return build.deployment if build.deployment - return unless build.starts_environment? + return unless build.deployment_job? deployment = ::Deployment.new(attributes(build, environment)) diff --git a/app/services/design_management/generate_image_versions_service.rb b/app/services/design_management/generate_image_versions_service.rb index 3ff239b59cc..0771ada72a1 100644 --- a/app/services/design_management/generate_image_versions_service.rb +++ b/app/services/design_management/generate_image_versions_service.rb @@ -69,17 +69,15 @@ module DesignManagement # The LFS pointer file data contains an "OID" that lets us retrieve `LfsObject` # records, which have an Uploader (`LfsObjectUploader`) for the original design file. def raw_files_by_path - @raw_files_by_path ||= begin - LfsObject.for_oids(blobs_by_oid.keys).each_with_object({}) do |lfs_object, h| - blob = blobs_by_oid[lfs_object.oid] - file = lfs_object.file.file - # The `CarrierWave::SanitizedFile` is loaded without knowing the `content_type` - # of the file, due to the file not having an extension. - # - # Set the content_type from the `Blob`. - file.content_type = blob.content_type - h[blob.path] = file - end + @raw_files_by_path ||= LfsObject.for_oids(blobs_by_oid.keys).each_with_object({}) do |lfs_object, h| + blob = blobs_by_oid[lfs_object.oid] + file = lfs_object.file.file + # The `CarrierWave::SanitizedFile` is loaded without knowing the `content_type` + # of the file, due to the file not having an extension. + # + # Set the content_type from the `Blob`. + file.content_type = blob.content_type + h[blob.path] = file end end diff --git a/app/services/environments/create_for_build_service.rb b/app/services/environments/create_for_build_service.rb index c46b66ac5b3..ff4da212002 100644 --- a/app/services/environments/create_for_build_service.rb +++ b/app/services/environments/create_for_build_service.rb @@ -3,10 +3,10 @@ module Environments # This class creates an environment record for a build (a pipeline job). class CreateForBuildService - def execute(build, merge_request: nil) + def execute(build) return unless build.instance_of?(::Ci::Build) && build.has_environment_keyword? - environment = to_resource(build, merge_request) + environment = to_resource(build) if environment.persisted? build.persisted_environment = environment @@ -21,12 +21,12 @@ module Environments private # rubocop: disable Performance/ActiveRecordSubtransactionMethods - def to_resource(build, merge_request) + def to_resource(build) build.project.environments.safe_find_or_create_by(name: build.expanded_environment_name) do |environment| # Initialize the attributes at creation environment.auto_stop_in = expanded_auto_stop_in(build) environment.tier = build.environment_tier_from_options - environment.merge_request = merge_request + environment.merge_request = build.pipeline.merge_request end end # rubocop: enable Performance/ActiveRecordSubtransactionMethods diff --git a/app/services/environments/schedule_to_delete_review_apps_service.rb b/app/services/environments/schedule_to_delete_review_apps_service.rb index 041b834f11b..8e9fe3300c4 100644 --- a/app/services/environments/schedule_to_delete_review_apps_service.rb +++ b/app/services/environments/schedule_to_delete_review_apps_service.rb @@ -68,7 +68,7 @@ module Environments end def mark_for_deletion(deletable_environments) - Environment.for_id(deletable_environments).schedule_to_delete + Environment.id_in(deletable_environments).schedule_to_delete end class Result diff --git a/app/services/error_tracking/list_projects_service.rb b/app/services/error_tracking/list_projects_service.rb index 625addaf915..2f23d47029c 100644 --- a/app/services/error_tracking/list_projects_service.rb +++ b/app/services/error_tracking/list_projects_service.rb @@ -19,19 +19,18 @@ module ErrorTracking end def project_error_tracking_setting - @project_error_tracking_setting ||= begin - (super || project.build_error_tracking_setting).tap do |setting| - setting.api_url = ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from( - api_host: params[:api_host], - organization_slug: 'org', - project_slug: 'proj' - ) - - setting.token = token(setting) - setting.enabled = true - end + (super || project.build_error_tracking_setting).tap do |setting| + setting.api_url = ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from( + api_host: params[:api_host], + organization_slug: 'org', + project_slug: 'proj' + ) + + setting.token = token(setting) + setting.enabled = true end end + strong_memoize_attr :project_error_tracking_setting def token(setting) # Use param token if not masked, otherwise use database token diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb index 662980fe506..bf4a26400e1 100644 --- a/app/services/event_create_service.rb +++ b/app/services/event_create_service.rb @@ -10,6 +10,10 @@ class EventCreateService IllegalActionError = Class.new(StandardError) + DEGIGN_EVENT_LABEL = 'usage_activity_by_stage_monthly.create.action_monthly_active_users_design_management' + MR_EVENT_LABEL = 'usage_activity_by_stage_monthly.create.merge_requests_users' + MR_EVENT_PROPERTY = 'merge_requests_users' + def open_issue(issue, current_user) create_record_event(issue, current_user, :created) end @@ -26,9 +30,11 @@ class EventCreateService create_record_event(merge_request, current_user, :created).tap do track_event(event_action: :created, event_target: MergeRequest, author_id: current_user.id) track_snowplow_event( - :created, - merge_request, - current_user + action: :created, + project: merge_request.project, + user: current_user, + label: MR_EVENT_LABEL, + property: MR_EVENT_PROPERTY ) end end @@ -37,9 +43,11 @@ class EventCreateService create_record_event(merge_request, current_user, :closed).tap do track_event(event_action: :closed, event_target: MergeRequest, author_id: current_user.id) track_snowplow_event( - :closed, - merge_request, - current_user + action: :closed, + project: merge_request.project, + user: current_user, + label: MR_EVENT_LABEL, + property: MR_EVENT_PROPERTY ) end end @@ -52,9 +60,11 @@ class EventCreateService create_record_event(merge_request, current_user, :merged).tap do track_event(event_action: :merged, event_target: MergeRequest, author_id: current_user.id) track_snowplow_event( - :merged, - merge_request, - current_user + action: :merged, + project: merge_request.project, + user: current_user, + label: MR_EVENT_LABEL, + property: MR_EVENT_PROPERTY ) end end @@ -80,11 +90,12 @@ class EventCreateService if note.is_a?(DiffNote) && note.for_merge_request? track_event(event_action: :commented, event_target: MergeRequest, author_id: current_user.id) track_snowplow_event( - :commented, - note, - current_user + action: :commented, + project: note.project, + user: current_user, + label: MR_EVENT_LABEL, + property: MR_EVENT_PROPERTY ) - end end end @@ -117,17 +128,10 @@ class EventCreateService records = create.zip([:created].cycle) + update.zip([:updated].cycle) return [] if records.empty? - if create.any? - old_track_snowplow_event(create.first, current_user, - Gitlab::UsageDataCounters::TrackUniqueEvents::DESIGN_ACTION, - :create, 'design_users') - end + event_meta = { user: current_user, label: DEGIGN_EVENT_LABEL, property: Gitlab::UsageDataCounters::TrackUniqueEvents::DESIGN_ACTION } + track_snowplow_event(action: :create, project: create.first.project, **event_meta) if create.any? - if update.any? - old_track_snowplow_event(update.first, current_user, - Gitlab::UsageDataCounters::TrackUniqueEvents::DESIGN_ACTION, - :update, 'design_users') - end + track_snowplow_event(action: :update, project: update.first.project, **event_meta) if update.any? create_record_events(records, current_user) end @@ -135,9 +139,13 @@ class EventCreateService def destroy_designs(designs, current_user) return [] unless designs.present? - old_track_snowplow_event(designs.first, current_user, - Gitlab::UsageDataCounters::TrackUniqueEvents::DESIGN_ACTION, - :destroy, 'design_users') + track_snowplow_event( + action: :destroy, + project: designs.first.project, + user: current_user, + label: DEGIGN_EVENT_LABEL, + property: Gitlab::UsageDataCounters::TrackUniqueEvents::DESIGN_ACTION + ) create_record_events(designs.zip([:destroyed].cycle), current_user) end @@ -229,7 +237,8 @@ class EventCreateService namespace: namespace, user: current_user, project: project, - context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: 'action_active_users_project_repo').to_context] + property: 'project_action', + context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: 'project_action').to_context] ) end @@ -270,33 +279,18 @@ class EventCreateService Gitlab::UsageDataCounters::TrackUniqueEvents.track_event(**params) end - # This will be deleted as a part of - # https://gitlab.com/groups/gitlab-org/-/epics/8641 - # once all the events are fixed - def old_track_snowplow_event(record, current_user, category, action, label) + def track_snowplow_event(action:, project:, user:, label:, property:) return unless Feature.enabled?(:route_hll_to_snowplow_phase2) - project = record.project - Gitlab::Tracking.event( - category.to_s, - action.to_s, - label: label, - project: project, - namespace: project.namespace, - user: current_user - ) - end - - def track_snowplow_event(action, record, user) - project = record.project Gitlab::Tracking.event( self.class.to_s, action.to_s, - label: 'usage_activity_by_stage_monthly.create.merge_requests_users', + label: label, namespace: project.namespace, user: user, project: project, - context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: 'merge_requests_users').to_context] + property: property.to_s, + context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: property.to_s).to_context] ) end end diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb index 7de56c037ed..71dd9501648 100644 --- a/app/services/git/branch_hooks_service.rb +++ b/app/services/git/branch_hooks_service.rb @@ -164,16 +164,27 @@ module Git end end - def unsigned_x509_shas(commits) - CommitSignatures::X509CommitSignature.unsigned_commit_shas(commits.map(&:sha)) + def signature_types + types = [ + ::CommitSignatures::GpgSignature, + ::CommitSignatures::X509CommitSignature + ] + + types.push(::CommitSignatures::SshSignature) if Feature.enabled?(:ssh_commit_signatures, project) + + types end - def unsigned_gpg_shas(commits) - CommitSignatures::GpgSignature.unsigned_commit_shas(commits.map(&:sha)) + def unsigned_commit_shas(commits) + commit_shas = commits.map(&:sha) + + signature_types + .map { |signature| signature.unsigned_commit_shas(commit_shas) } + .reduce(&:&) end def enqueue_update_signatures - unsigned = unsigned_x509_shas(limited_commits) & unsigned_gpg_shas(limited_commits) + unsigned = unsigned_commit_shas(limited_commits) return if unsigned.empty? signable = Gitlab::Git::Commit.shas_with_signatures(project.repository, unsigned) diff --git a/app/services/groups/group_links/create_service.rb b/app/services/groups/group_links/create_service.rb index 56ddf3ec0b4..52180c39972 100644 --- a/app/services/groups/group_links/create_service.rb +++ b/app/services/groups/group_links/create_service.rb @@ -2,7 +2,7 @@ module Groups module GroupLinks - class CreateService < Groups::BaseService + class CreateService < ::Groups::BaseService include GroupLinkable def initialize(group, shared_with_group, user, params) diff --git a/app/services/groups/group_links/destroy_service.rb b/app/services/groups/group_links/destroy_service.rb index 4d74b5f32e2..d1f16775ab3 100644 --- a/app/services/groups/group_links/destroy_service.rb +++ b/app/services/groups/group_links/destroy_service.rb @@ -2,7 +2,7 @@ module Groups module GroupLinks - class DestroyService < BaseService + class DestroyService < ::Groups::BaseService def execute(one_or_more_links, skip_authorization: false) unless skip_authorization || group && can?(current_user, :admin_group_member, group) return error('Not Found', 404) diff --git a/app/services/groups/group_links/update_service.rb b/app/services/groups/group_links/update_service.rb index a1411de36d6..244ec2254a8 100644 --- a/app/services/groups/group_links/update_service.rb +++ b/app/services/groups/group_links/update_service.rb @@ -2,7 +2,7 @@ module Groups module GroupLinks - class UpdateService < BaseService + class UpdateService < ::Groups::BaseService def initialize(group_link, user = nil) super(group_link.shared_group, user) diff --git a/app/services/groups/import_export/import_service.rb b/app/services/groups/import_export/import_service.rb index 4092ded67bc..ac181245986 100644 --- a/app/services/groups/import_export/import_service.rb +++ b/app/services/groups/import_export/import_service.rb @@ -8,6 +8,7 @@ module Groups def initialize(group:, user:) @group = group @current_user = user + @user_role = user_role @shared = Gitlab::ImportExport::Shared.new(@group) @logger = Gitlab::Import::Logger.build end @@ -31,6 +32,14 @@ module Groups if valid_user_permissions? && import_file && restorers.all?(&:restore) notify_success + Gitlab::Tracking.event( + self.class.name, + 'create', + label: 'import_access_level', + user: current_user, + extra: { user_role: user_role, import_type: 'import_group_from_file' } + ) + group else notify_error! @@ -43,6 +52,15 @@ module Groups private + def user_role + # rubocop:disable CodeReuse/ActiveRecord, Style/MultilineTernaryOperator + access_level = group.parent ? + current_user&.group_members&.find_by(source_id: group.parent&.id)&.access_level : + Gitlab::Access::OWNER + Gitlab::Access.human_access(access_level) + # rubocop:enable CodeReuse/ActiveRecord, Style/MultilineTernaryOperator + end + def import_file @import_file ||= Gitlab::ImportExport::FileImporter.import( importable: group, diff --git a/app/services/import/base_service.rb b/app/services/import/base_service.rb index ab3e9c7abba..6b5adcbc39e 100644 --- a/app/services/import/base_service.rb +++ b/app/services/import/base_service.rb @@ -35,5 +35,30 @@ module Import def success(project) super().merge(project: project, status: :success) end + + def track_access_level(import_type) + Gitlab::Tracking.event( + self.class.name, + 'create', + label: 'import_access_level', + user: current_user, + extra: { user_role: user_role, import_type: import_type } + ) + end + + def user_role + if current_user.id == target_namespace.owner_id + 'Owner' + else + access_level = current_user&.group_members&.find_by(source_id: target_namespace.id)&.access_level + + case access_level + when nil + 'Not a member' + else + Gitlab::Access.human_access(access_level) + end + end + end end end diff --git a/app/services/import/bitbucket_server_service.rb b/app/services/import/bitbucket_server_service.rb index 20f6c987c92..f7f17f1e53e 100644 --- a/app/services/import/bitbucket_server_service.rb +++ b/app/services/import/bitbucket_server_service.rb @@ -19,6 +19,8 @@ module Import project = create_project(credentials) + track_access_level('bitbucket') + if project.persisted? success(project) elsif project.errors[:import_source_disabled].present? diff --git a/app/services/import/github/gists_import_service.rb b/app/services/import/github/gists_import_service.rb new file mode 100644 index 00000000000..df1bbe306e7 --- /dev/null +++ b/app/services/import/github/gists_import_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Import + module Github + class GistsImportService < ::BaseService + def initialize(user, params) + @current_user = user + @params = params + end + + def execute + return error('Import already in progress', 422) if import_status.started? + + start_import + success + end + + private + + def import_status + @import_status ||= Gitlab::GithubGistsImport::Status.new(current_user.id) + end + + def encrypted_token + Gitlab::CryptoHelper.aes256_gcm_encrypt(params[:github_access_token]) + end + + def start_import + Gitlab::GithubGistsImport::StartImportWorker.perform_async(current_user.id, encrypted_token) + import_status.start! + end + end + end +end diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb index a60963e28c7..2378a4b11b1 100644 --- a/app/services/import/github_service.rb +++ b/app/services/import/github_service.rb @@ -13,6 +13,7 @@ module Import return context_error if context_error project = create_project(access_params, provider) + track_access_level('github') if project.persisted? store_import_settings(project) diff --git a/app/services/import/gitlab_projects/file_acquisition_strategies/file_upload.rb b/app/services/import/gitlab_projects/file_acquisition_strategies/file_upload.rb index 8bee3067d6c..1652bdab5b8 100644 --- a/app/services/import/gitlab_projects/file_acquisition_strategies/file_upload.rb +++ b/app/services/import/gitlab_projects/file_acquisition_strategies/file_upload.rb @@ -8,7 +8,7 @@ module Import validate :uploaded_file - def initialize(current_user: nil, params:) + def initialize(params:, current_user: nil) @params = params end diff --git a/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file.rb b/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file.rb index ac58711a0ac..e179a14c497 100644 --- a/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file.rb +++ b/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file.rb @@ -21,7 +21,7 @@ module Import # whole condition of this validation: validates_with RemoteFileValidator, if: -> { validate_aws_s3? || !s3_request? } - def initialize(current_user: nil, params:) + def initialize(params:, current_user: nil) @params = params end diff --git a/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file_s3.rb b/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file_s3.rb index 5cbca53582d..7599343d4e1 100644 --- a/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file_s3.rb +++ b/app/services/import/gitlab_projects/file_acquisition_strategies/remote_file_s3.rb @@ -25,7 +25,7 @@ module Import # we add an expiration a bit longer to ensure it won't expire during the import. URL_EXPIRATION = 28.hours.seconds - def initialize(current_user: nil, params:) + def initialize(params:, current_user: nil) @params = params end diff --git a/app/services/incident_management/incidents/create_service.rb b/app/services/incident_management/incidents/create_service.rb index f44842650b7..49019278871 100644 --- a/app/services/incident_management/incidents/create_service.rb +++ b/app/services/incident_management/incidents/create_service.rb @@ -23,7 +23,7 @@ module IncidentManagement description: description, issue_type: ISSUE_TYPE, severity: severity, - alert_management_alert: alert + alert_management_alerts: [alert].compact }, spam_params: nil ).execute diff --git a/app/services/incident_management/link_alerts/base_service.rb b/app/services/incident_management/link_alerts/base_service.rb new file mode 100644 index 00000000000..474a63ab528 --- /dev/null +++ b/app/services/incident_management/link_alerts/base_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module IncidentManagement + module LinkAlerts + class BaseService < ::BaseProjectService + private + + attr_reader :incident + + def allowed? + current_user&.can?(:admin_issue, project) + end + + def success + ServiceResponse.success(payload: { incident: incident }) + end + + def error(message) + ServiceResponse.error(message: message) + end + + def error_no_permissions + error(_('You have insufficient permissions to manage alerts for this project')) + end + end + end +end diff --git a/app/services/incident_management/link_alerts/create_service.rb b/app/services/incident_management/link_alerts/create_service.rb new file mode 100644 index 00000000000..5e5a974efdd --- /dev/null +++ b/app/services/incident_management/link_alerts/create_service.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module IncidentManagement + module LinkAlerts + class CreateService < BaseService + # @param incident [Issue] an incident to link alerts + # @param current_user [User] + # @param alert_references [[String]] a list of alert references. Can be either a short reference or URL + # Examples: + # "^alert#IID" + # "https://gitlab.com/company/project/-/alert_management/IID/details" + def initialize(incident, current_user, alert_references) + @incident = incident + @current_user = current_user + @alert_references = alert_references + + super(project: incident.project, current_user: current_user) + end + + def execute + return error_no_permissions unless allowed? + + references = extract_alerts_from_references + incident.alert_management_alerts << references if references.present? + + success + end + + private + + attr_reader :alert_references + + def extract_alerts_from_references + text = alert_references.join(' ') + extractor = Gitlab::ReferenceExtractor.new(project, current_user) + extractor.analyze(text, {}) + + extractor.alerts + end + end + end +end diff --git a/app/services/incident_management/link_alerts/destroy_service.rb b/app/services/incident_management/link_alerts/destroy_service.rb new file mode 100644 index 00000000000..baeedaf74b6 --- /dev/null +++ b/app/services/incident_management/link_alerts/destroy_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module IncidentManagement + module LinkAlerts + class DestroyService < BaseService + # @param incident [Issue] an incident to unlink alert from + # @param current_user [User] + # @param alert [AlertManagement::Alert] an alert to unlink from the incident + def initialize(incident, current_user, alert) + @incident = incident + @current_user = current_user + @alert = alert + + super(project: incident.project, current_user: current_user) + end + + def execute + return error_no_permissions unless allowed? + + incident.alert_management_alerts.delete(alert) + + success + end + + private + + attr_reader :alert + end + end +end diff --git a/app/services/incident_management/pager_duty/process_webhook_service.rb b/app/services/incident_management/pager_duty/process_webhook_service.rb index a49e639ea62..3ce2674616e 100644 --- a/app/services/incident_management/pager_duty/process_webhook_service.rb +++ b/app/services/incident_management/pager_duty/process_webhook_service.rb @@ -9,8 +9,8 @@ module IncidentManagement # https://developer.pagerduty.com/docs/webhooks/webhook-behavior/#size-limit PAGER_DUTY_PAYLOAD_SIZE_LIMIT = 55.kilobytes - # https://developer.pagerduty.com/docs/webhooks/v2-overview/#webhook-types - PAGER_DUTY_PROCESSABLE_EVENT_TYPES = %w(incident.trigger).freeze + # https://developer.pagerduty.com/docs/db0fa8c8984fc-overview#event-types + PAGER_DUTY_PROCESSABLE_EVENT_TYPES = %w(incident.triggered).freeze def initialize(project, payload) super(project: project) @@ -33,16 +33,18 @@ module IncidentManagement attr_reader :payload def process_incidents - pager_duty_processable_events.each do |event| - ::IncidentManagement::PagerDuty::ProcessIncidentWorker.perform_async(project.id, event['incident']) - end + event = pager_duty_processable_event + return unless event + + ::IncidentManagement::PagerDuty::ProcessIncidentWorker + .perform_async(project.id, event['incident']) end - def pager_duty_processable_events - strong_memoize(:pager_duty_processable_events) do - ::PagerDuty::WebhookPayloadParser - .call(payload.to_h) - .filter { |msg| msg['event'].to_s.in?(PAGER_DUTY_PROCESSABLE_EVENT_TYPES) } + def pager_duty_processable_event + strong_memoize(:pager_duty_processable_event) do + event = ::PagerDuty::WebhookPayloadParser.call(payload.to_h) + + event if event['event'].to_s.in?(PAGER_DUTY_PROCESSABLE_EVENT_TYPES) end end diff --git a/app/services/incident_management/timeline_events/base_service.rb b/app/services/incident_management/timeline_events/base_service.rb index 7168e2fdd38..e0ca4320091 100644 --- a/app/services/incident_management/timeline_events/base_service.rb +++ b/app/services/incident_management/timeline_events/base_service.rb @@ -5,6 +5,8 @@ module IncidentManagement class BaseService include Gitlab::Utils::UsageData + AUTOCREATE_TAGS = [TimelineEventTag::START_TIME_TAG_NAME, TimelineEventTag::END_TIME_TAG_NAME].freeze + def allowed? user&.can?(:admin_incident_management_timeline_event, incident) end @@ -24,6 +26,33 @@ module IncidentManagement def error_in_save(timeline_event) error(timeline_event.errors.full_messages.to_sentence) end + + def track_timeline_event(event, project) + namespace = project.namespace + track_usage_event(event, user.id) + + return unless Feature.enabled?(:route_hll_to_snowplow_phase2, namespace) + + Gitlab::Tracking.event( + self.class.to_s, + event, + project: project, + namespace: namespace, + user: user, + label: 'redis_hll_counters.incident_management.incident_management_total_unique_counts_monthly', + context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: event).to_context] + ) + end + + def auto_create_predefined_tags(new_tags) + new_tags = new_tags.map(&:downcase) + + tags_to_create = AUTOCREATE_TAGS.select { |tag| tag.downcase.in?(new_tags) } + + tags_to_create.each do |name| + project.incident_management_timeline_event_tags.create(name: name) + end + end end end end diff --git a/app/services/incident_management/timeline_events/create_service.rb b/app/services/incident_management/timeline_events/create_service.rb index 71ff5b64515..06e8fc32335 100644 --- a/app/services/incident_management/timeline_events/create_service.rb +++ b/app/services/incident_management/timeline_events/create_service.rb @@ -5,7 +5,6 @@ module IncidentManagement DEFAULT_ACTION = 'comment' DEFAULT_EDITABLE = false DEFAULT_AUTO_CREATED = false - AUTOCREATE_TAGS = [TimelineEventTag::START_TIME_TAG_NAME, TimelineEventTag::END_TIME_TAG_NAME].freeze class CreateService < TimelineEvents::BaseService def initialize(incident, user, params) @@ -106,7 +105,7 @@ module IncidentManagement create_timeline_event_tag_links(timeline_event, params[:timeline_event_tag_names]) - track_usage_event(:incident_management_timeline_event_created, user.id) + track_timeline_event("incident_management_timeline_event_created", project) success(timeline_event) else @@ -153,16 +152,6 @@ module IncidentManagement IncidentManagement::TimelineEventTagLink.insert_all(tag_links) if tag_links.any? end - def auto_create_predefined_tags(new_tags) - new_tags = new_tags.map(&:downcase) - - tags_to_create = AUTOCREATE_TAGS.select { |tag| tag.downcase.in?(new_tags) } - - tags_to_create.each do |name| - project.incident_management_timeline_event_tags.create(name: name) - end - end - def validate_tags(project, tag_names) return [] unless tag_names&.any? diff --git a/app/services/incident_management/timeline_events/destroy_service.rb b/app/services/incident_management/timeline_events/destroy_service.rb index e1c6bbbdb85..aba46cdda27 100644 --- a/app/services/incident_management/timeline_events/destroy_service.rb +++ b/app/services/incident_management/timeline_events/destroy_service.rb @@ -18,7 +18,7 @@ module IncidentManagement if timeline_event.destroy add_system_note(incident, user) - track_usage_event(:incident_management_timeline_event_deleted, user.id) + track_timeline_event('incident_management_timeline_event_deleted', project) success(timeline_event) else error_in_save(timeline_event) diff --git a/app/services/incident_management/timeline_events/update_service.rb b/app/services/incident_management/timeline_events/update_service.rb index 8d4e29c6857..4949a5a0bd1 100644 --- a/app/services/incident_management/timeline_events/update_service.rb +++ b/app/services/incident_management/timeline_events/update_service.rb @@ -13,21 +13,41 @@ module IncidentManagement def initialize(timeline_event, user, params) @timeline_event = timeline_event @incident = timeline_event.incident + @project = incident.project @user = user @note = params[:note] @occurred_at = params[:occurred_at] @validation_context = VALIDATION_CONTEXT + @timeline_event_tags = params[:timeline_event_tag_names] end def execute return error_no_permissions unless allowed? - timeline_event.assign_attributes(update_params) + unless timeline_event_tags.nil? + auto_create_predefined_tags(timeline_event_tags) - if timeline_event.save(context: validation_context) + # Refetches the tag objects to consider predefined tags as well + new_tags = timeline_event + .project + .incident_management_timeline_event_tags + .by_names(timeline_event_tags) + + non_existing_tags = validate_tags(new_tags) + + return error("#{_("Following tags don't exist")}: #{non_existing_tags}") if non_existing_tags.any? + end + + begin + timeline_event_saved = update_timeline_event_and_event_tags(new_tags) + rescue ActiveRecord::RecordInvalid + error_in_save(timeline_event) + end + + if timeline_event_saved add_system_note(timeline_event) - track_usage_event(:incident_management_timeline_event_edited, user.id) + track_timeline_event('incident_management_timeline_event_edited', timeline_event.project) success(timeline_event) else error_in_save(timeline_event) @@ -36,7 +56,18 @@ module IncidentManagement private - attr_reader :timeline_event, :incident, :user, :note, :occurred_at, :validation_context + attr_reader :timeline_event, :incident, :project, :user, + :note, :occurred_at, :validation_context, :timeline_event_tags + + def update_timeline_event_and_event_tags(new_tags) + ApplicationRecord.transaction do + timeline_event.timeline_event_tags = new_tags unless timeline_event_tags.nil? + + timeline_event.assign_attributes(update_params) + + timeline_event.save!(context: validation_context) + end + end def update_params { updated_by_user: user, note: note, occurred_at: occurred_at }.compact @@ -61,6 +92,10 @@ module IncidentManagement :none end + def validate_tags(new_tags) + timeline_event_tags.map(&:downcase) - new_tags.map(&:name).map(&:downcase) + end + def allowed? user&.can?(:edit_incident_management_timeline_event, timeline_event) end diff --git a/app/services/issuable/discussions_list_service.rb b/app/services/issuable/discussions_list_service.rb index 7aa0363af01..1e5e37e4e1b 100644 --- a/app/services/issuable/discussions_list_service.rb +++ b/app/services/issuable/discussions_list_service.rb @@ -16,7 +16,7 @@ module Issuable end def execute - return Note.none unless can_read_issuable? + return Note.none unless can_read_issuable_notes? notes = NotesFinder.new(current_user, params.merge({ target: issuable, project: issuable.project })) .execute.with_web_entity_associations.inc_relations_for_view.fresh @@ -39,12 +39,9 @@ module Issuable notes = prepare_notes_for_rendering(notes) - # TODO: optimize this permission check. - # Given this loads notes on a single issuable and current permission system, we should not have to check - # permission on every single note. We should be able to check permission on the given issuable or its container, - # which should result in just one permission check. Perhaps that should also either be passed to NotesFinder or - # should be done in NotesFinder, which would decide right away if it would need to return no notes - # or if it should just filter out internal notes. + # we need to check the permission on every note, because some system notes for instance can have references to + # resources that some user do not have read access, so those notes are filtered out from the list of notes. + # see Note#all_referenced_mentionables_allowed? notes = notes.select { |n| n.readable_by?(current_user) } Discussion.build_collection(notes, issuable) @@ -61,10 +58,11 @@ module Issuable end end - def can_read_issuable? + def can_read_issuable_notes? return Ability.allowed?(current_user, :read_security_resource, issuable) if issuable.is_a?(Vulnerability) - Ability.allowed?(current_user, :"read_#{issuable.to_ability_name}", issuable) + Ability.allowed?(current_user, :"read_#{issuable.to_ability_name}", issuable) && + Ability.allowed?(current_user, :read_note, issuable) end end end diff --git a/app/services/issue_links/create_service.rb b/app/services/issue_links/create_service.rb index 7f509f3b3e0..80c6af88f21 100644 --- a/app/services/issue_links/create_service.rb +++ b/app/services/issue_links/create_service.rb @@ -5,9 +5,7 @@ module IssueLinks include IncidentManagement::UsageData def linkable_issuables(issues) - @linkable_issuables ||= begin - issues.select { |issue| can?(current_user, :admin_issue_link, issue) } - end + @linkable_issuables ||= issues.select { |issue| can?(current_user, :admin_issue_link, issue) } end def previous_related_issuables diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index 28ea6b0ebf8..10407e99715 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -114,6 +114,11 @@ module Issues Milestones::IssuesCountService.new(milestone).delete_cache end + + override :allowed_create_params + def allowed_create_params(params) + super(params).except(:issue_type, :work_item_type_id, :work_item_type) + end end end diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index da888386e0a..4f6a859e20e 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -56,7 +56,7 @@ module Issues end def perform_incident_management_actions(issue) - resolve_alert(issue) + resolve_alerts(issue) resolve_incident(issue) end @@ -71,12 +71,17 @@ module Issues SystemNoteService.change_status(issue, issue.project, current_user, issue.state, current_commit) end - def resolve_alert(issue) - return unless alert = issue.alert_management_alert + def resolve_alerts(issue) + issue.alert_management_alerts.each { |alert| resolve_alert(alert) } + end + + def resolve_alert(alert) return if alert.resolved? + issue = alert.issue + if alert.resolve - SystemNoteService.change_alert_status(alert, current_user, " by closing incident #{issue.to_reference(project)}") + SystemNoteService.change_alert_status(alert, User.alert_bot, " because #{current_user.to_reference} closed incident #{issue.to_reference(project)}") else Gitlab::AppLogger.warn( message: 'Cannot resolve an associated Alert Management alert', diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 89b35bbab24..afad8d0c6bf 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -13,7 +13,7 @@ module Issues # spam_checking is likely to be necessary. However, if there is not a request available in scope # in the caller (for example, an issue created via email) and the required arguments to the # SpamParams constructor are not otherwise available, spam_params: must be explicitly passed as nil. - def initialize(project:, current_user: nil, params: {}, spam_params:, build_service: nil) + def initialize(project:, spam_params:, current_user: nil, params: {}, build_service: nil) @extra_params = params.delete(:extra_params) || {} super(project: project, current_user: current_user, params: params) @spam_params = spam_params diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index 6366ff4076b..f7f7d85611b 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -19,6 +19,7 @@ module Issues # to receive service desk emails on the new moved issue. update_service_desk_sent_notifications + copy_email_participants queue_copy_designs new_entity @@ -49,6 +50,18 @@ module Issues .sent_notifications.update_all(project_id: new_entity.project_id, noteable_id: new_entity.id) end + def copy_email_participants + new_attributes = { id: nil, issue_id: new_entity.id } + + new_participants = original_entity.issue_email_participants.dup + + new_participants.each do |participant| + participant.assign_attributes(new_attributes) + end + + IssueEmailParticipant.bulk_insert!(new_participants) + end + override :update_old_entity def update_old_entity super diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 0aed9e3ba40..71cc5581ae6 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -146,7 +146,7 @@ module Issues # don't enqueue immediately to prevent todos removal in case of a mistake TodosDestroyer::ConfidentialIssueWorker.perform_in(Todo::WAIT_FOR_DELETE, issue.id) if issue.confidential? create_confidentiality_note(issue) - track_usage_event(:incident_management_incident_change_confidential, current_user.id) + track_incident_action(current_user, issue, :incident_change_confidential) end end diff --git a/app/services/jira_connect/create_asymmetric_jwt_service.rb b/app/services/jira_connect/create_asymmetric_jwt_service.rb index 71aba6feddd..0f24128c20b 100644 --- a/app/services/jira_connect/create_asymmetric_jwt_service.rb +++ b/app/services/jira_connect/create_asymmetric_jwt_service.rb @@ -4,10 +4,11 @@ module JiraConnect class CreateAsymmetricJwtService ARGUMENT_ERROR_MESSAGE = 'jira_connect_installation is not a proxy installation' - def initialize(jira_connect_installation) + def initialize(jira_connect_installation, event: :installed) raise ArgumentError, ARGUMENT_ERROR_MESSAGE unless jira_connect_installation.proxy? @jira_connect_installation = jira_connect_installation + @event = event end def execute @@ -30,12 +31,18 @@ module JiraConnect def qsh_claim Atlassian::Jwt.create_query_string_hash( - @jira_connect_installation.audience_installed_event_url, + audience_event_url, 'POST', @jira_connect_installation.audience_url ) end + def audience_event_url + return @jira_connect_installation.audience_uninstalled_event_url if @event == :uninstalled + + @jira_connect_installation.audience_installed_event_url + end + def private_key @private_key ||= OpenSSL::PKey::RSA.generate(3072) end diff --git a/app/services/jira_connect_installations/proxy_lifecycle_event_service.rb b/app/services/jira_connect_installations/proxy_lifecycle_event_service.rb new file mode 100644 index 00000000000..d94d9e1324e --- /dev/null +++ b/app/services/jira_connect_installations/proxy_lifecycle_event_service.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module JiraConnectInstallations + class ProxyLifecycleEventService + SUPPOERTED_EVENTS = %i[installed uninstalled].freeze + + def self.execute(installation, event, instance_url) + new(installation, event, instance_url).execute + end + + def initialize(installation, event, instance_url) + # To ensure the event is sent to the right instance, this makes + # a copy of the installation and assigns the instance_url + # + # The installation might be modified already with a new instance_url. + # This can be the case for an uninstalled event. + # The installation is updated first, and the uninstalled event has to be sent to + # the old instance_url. + @installation = installation.dup + @installation.instance_url = instance_url + + @event = event.to_sym + + raise(ArgumentError, "Unknown event '#{@event}'") unless SUPPOERTED_EVENTS.include?(@event) + end + + def execute + result = send_hook + + return ServiceResponse.new(status: :success) if result.code == 200 + + log_unsuccessful_response(result.code, result.body) + + ServiceResponse.error(message: { type: :response_error, code: result.code }) + rescue *Gitlab::HTTP::HTTP_ERRORS => error + ServiceResponse.error(message: { type: :network_error, message: error.message }) + end + + private + + attr_reader :installation, :event + + def send_hook + Gitlab::HTTP.post(hook_uri, body: body) + end + + def hook_uri + case event + when :installed + installation.audience_installed_event_url + when :uninstalled + installation.audience_uninstalled_event_url + end + end + + def body + return base_body unless event == :installed + + base_body.merge(installed_body) + end + + def base_body + { + clientKey: installation.client_key, + jwt: jwt_token, + eventType: event + } + end + + def installed_body + { + sharedSecret: installation.shared_secret, + baseUrl: installation.base_url + } + end + + def jwt_token + @jwt_token ||= JiraConnect::CreateAsymmetricJwtService.new(@installation, event: event).execute + end + + def log_unsuccessful_response(status_code, body) + Gitlab::IntegrationsLogger.info( + integration: 'JiraConnect', + message: 'Proxy lifecycle event received error response', + event_type: event, + status_code: status_code, + body: body + ) + end + end +end diff --git a/app/services/jira_connect_installations/update_service.rb b/app/services/jira_connect_installations/update_service.rb new file mode 100644 index 00000000000..b2b6f2a91f2 --- /dev/null +++ b/app/services/jira_connect_installations/update_service.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module JiraConnectInstallations + class UpdateService + def self.execute(installation, update_params) + new(installation, update_params).execute + end + + def initialize(installation, update_params) + @installation = installation + @update_params = update_params + end + + def execute + return update_error unless @installation.update(@update_params) + + if @installation.instance_url? + hook_result = ProxyLifecycleEventService.execute(@installation, :installed, @installation.instance_url) + + if instance_url_changed? && hook_result.error? + @installation.update!(instance_url: @installation.instance_url_before_last_save) + + return instance_installation_creation_error(hook_result.message) + end + end + + send_uninstalled_hook if instance_url_changed? + + ServiceResponse.new(status: :success) + end + + private + + def instance_url_changed? + @installation.instance_url_before_last_save != @installation.instance_url + end + + def send_uninstalled_hook + return if @installation.instance_url_before_last_save.blank? + + JiraConnect::SendUninstalledHookWorker.perform_async( + @installation.id, + @installation.instance_url_before_last_save + ) + end + + def instance_installation_creation_error(error_message) + message = if error_message[:type] == :response_error + "Could not be installed on the instance. Error response code #{error_message[:code]}" + else + 'Could not be installed on the instance. Network error' + end + + ServiceResponse.error(message: { instance_url: [message] }) + end + + def update_error + ServiceResponse.error(message: @installation.errors) + end + end +end diff --git a/app/services/jira_import/start_import_service.rb b/app/services/jira_import/start_import_service.rb index 9cd56cf339e..ef376e2f24a 100644 --- a/app/services/jira_import/start_import_service.rb +++ b/app/services/jira_import/start_import_service.rb @@ -73,7 +73,7 @@ module JiraImport jira_imports_for_project = project.jira_imports.by_jira_project_key(jira_project_key).size + 1 title = "jira-import::#{jira_project_key}-#{jira_imports_for_project}" description = "Label for issues that were imported from Jira on #{import_start_time.strftime('%Y-%m-%d %H:%M:%S')}" - color = "#{::Gitlab::Color.color_for(title)}" + color = ::Gitlab::Color.color_for(title).to_s { title: title, description: description, color: color } end diff --git a/app/services/markup/rendering_service.rb b/app/services/markup/rendering_service.rb index c4abbb6b5b0..cd89c170efa 100644 --- a/app/services/markup/rendering_service.rb +++ b/app/services/markup/rendering_service.rb @@ -2,12 +2,6 @@ module Markup class RenderingService - # Let's increase the render timeout - # For a smaller one, a test that renders the blob content statically fails - # We can consider removing this custom timeout when markup_rendering_timeout FF is removed: - # https://gitlab.com/gitlab-org/gitlab/-/issues/365358 - RENDER_TIMEOUT = 5.seconds - def initialize(text, file_name: nil, context: {}, postprocess_context: {}) @text = text @file_name = file_name @@ -19,7 +13,7 @@ module Markup return '' unless text.present? return context.delete(:rendered) if context.has_key?(:rendered) - html = file_name ? markup_unsafe : markdown_unsafe + html = markup_unsafe return '' unless html.present? @@ -29,27 +23,17 @@ module Markup private def markup_unsafe - markup = proc do - if Gitlab::MarkupHelper.gitlab_markdown?(file_name) - markdown_unsafe - elsif Gitlab::MarkupHelper.asciidoc?(file_name) - asciidoc_unsafe - elsif Gitlab::MarkupHelper.plain?(file_name) - plain_unsafe - else - other_markup_unsafe - end - end - - if Feature.enabled?(:markup_rendering_timeout, context[:project]) - Gitlab::RenderTimeout.timeout(foreground: RENDER_TIMEOUT, &markup) + return markdown_unsafe unless file_name + + if Gitlab::MarkupHelper.gitlab_markdown?(file_name) + markdown_unsafe + elsif Gitlab::MarkupHelper.asciidoc?(file_name) + asciidoc_unsafe + elsif Gitlab::MarkupHelper.plain?(file_name) + plain_unsafe else - markup.call + other_markup_unsafe end - rescue StandardError => e - Gitlab::ErrorTracking.track_exception(e, project_id: context[:project]&.id, file_name: file_name) - - ActionController::Base.helpers.simple_format(text) end def markdown_unsafe diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb index f18269454e3..5afc13701e0 100644 --- a/app/services/members/destroy_service.rb +++ b/app/services/members/destroy_service.rb @@ -15,15 +15,15 @@ module Members @skip_auth = skip_authorization last_owner = true - in_lock("delete_members:#{member.source.class}:#{member.source.id}") do + in_lock("delete_members:#{member.source.class}:#{member.source.id}", sleep_sec: 0.1.seconds) do break if member.is_a?(GroupMember) && member.source.last_owner?(member.user) last_owner = false member.destroy - member.user&.invalidate_cache_counts end unless last_owner + member.user&.invalidate_cache_counts delete_member_associations(member, skip_subresources, unassign_issuables) end diff --git a/app/services/merge_requests/after_create_service.rb b/app/services/merge_requests/after_create_service.rb index 20b32dbc2a0..9e39aa94246 100644 --- a/app/services/merge_requests/after_create_service.rb +++ b/app/services/merge_requests/after_create_service.rb @@ -22,7 +22,7 @@ module MergeRequests def prepare_merge_request(merge_request) event_service.open_mr(merge_request, current_user) - merge_request_activity_counter.track_create_mr_action(user: current_user) + merge_request_activity_counter.track_create_mr_action(user: current_user, merge_request: merge_request) merge_request_activity_counter.track_mr_including_ci_config(user: current_user, merge_request: merge_request) notification_service.new_merge_request(merge_request, current_user) diff --git a/app/services/merge_requests/approval_service.rb b/app/services/merge_requests/approval_service.rb index 72f398ce415..8560a15b7c4 100644 --- a/app/services/merge_requests/approval_service.rb +++ b/app/services/merge_requests/approval_service.rb @@ -13,6 +13,7 @@ module MergeRequests merge_request_activity_counter.track_approve_mr_action(user: current_user, merge_request: merge_request) trigger_merge_request_merge_status_updated(merge_request) trigger_merge_request_reviewers_updated(merge_request) + trigger_merge_request_approval_state_updated(merge_request) # Approval side effects (things not required to be done immediately but # should happen after a successful approval) should be done asynchronously diff --git a/app/services/merge_requests/assign_issues_service.rb b/app/services/merge_requests/assign_issues_service.rb index f016c16e816..c107280efb1 100644 --- a/app/services/merge_requests/assign_issues_service.rb +++ b/app/services/merge_requests/assign_issues_service.rb @@ -3,15 +3,13 @@ module MergeRequests class AssignIssuesService < BaseProjectService def assignable_issues - @assignable_issues ||= begin - if current_user == merge_request.author - closes_issues.select do |issue| - !issue.is_a?(ExternalIssue) && !issue.assignees.present? && can?(current_user, :admin_issue, issue) - end - else - [] - end - end + @assignable_issues ||= if current_user == merge_request.author + closes_issues.select do |issue| + !issue.is_a?(ExternalIssue) && !issue.assignees.present? && can?(current_user, :admin_issue, issue) + end + else + [] + end end def execute diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index e7ab2c062ee..468cadb03c7 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -59,6 +59,8 @@ module MergeRequests merge_request_activity_counter.track_users_review_requested(users: new_reviewers) merge_request_activity_counter.track_reviewers_changed_action(user: current_user) trigger_merge_request_reviewers_updated(merge_request) + + capture_suggested_reviewers_accepted(merge_request) end def cleanup_environments(merge_request) @@ -137,6 +139,7 @@ module MergeRequests end filter_reviewer(merge_request) + filter_suggested_reviewers end def filter_reviewer(merge_request) @@ -163,6 +166,10 @@ module MergeRequests end end + def filter_suggested_reviewers + # Implemented in EE + end + def merge_request_metrics_service(merge_request) MergeRequestMetricsService.new(merge_request.metrics) end @@ -253,6 +260,14 @@ module MergeRequests def trigger_merge_request_merge_status_updated(merge_request) GraphqlTriggers.merge_request_merge_status_updated(merge_request) end + + def trigger_merge_request_approval_state_updated(merge_request) + GraphqlTriggers.merge_request_approval_state_updated(merge_request) + end + + def capture_suggested_reviewers_accepted(merge_request) + # Implemented in EE + end end end diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index cc786ac02bd..b9a681f29db 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -224,6 +224,7 @@ module MergeRequests # def assign_title_and_description assign_description_from_repository_template + replace_variables_in_description assign_title_and_description_from_commits merge_request.title ||= title_from_issue if target_project.issues_enabled? || target_project.external_issue_tracker merge_request.title ||= source_branch.titleize.humanize @@ -318,6 +319,15 @@ module MergeRequests merge_request.description = repository_template.content end + def replace_variables_in_description + return unless merge_request.description.present? + + merge_request.description = ::Gitlab::MergeRequests::MessageGenerator.new( + merge_request: merge_request, + current_user: current_user + ).new_mr_description + end + def issue_iid strong_memoize(:issue_iid) do @params_issue_iid || begin diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index 04d08f257f1..8fa80dc3513 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -39,7 +39,7 @@ module MergeRequests # open while the Gitaly RPC waits. To avoid an idle in transaction # timeout, we do this before we attempt to save the merge request. - if Feature.enabled?(:async_merge_request_diff_creation, current_user) + if Feature.enabled?(:async_merge_request_diff_creation, merge_request.target_project) merge_request.skip_ensure_merge_request_diff = true else merge_request.eager_fetch_ref! diff --git a/app/services/merge_requests/push_options_handler_service.rb b/app/services/merge_requests/push_options_handler_service.rb index aa52349b0ee..711978dc3f7 100644 --- a/app/services/merge_requests/push_options_handler_service.rb +++ b/app/services/merge_requests/push_options_handler_service.rb @@ -7,7 +7,7 @@ module MergeRequests attr_reader :errors, :changes, :push_options, :target_project - def initialize(project:, current_user:, params: {}, changes:, push_options:) + def initialize(project:, current_user:, changes:, push_options:, params: {}) super(project: project, current_user: current_user, params: params) @target_project = @project.default_merge_request_target diff --git a/app/services/merge_requests/remove_approval_service.rb b/app/services/merge_requests/remove_approval_service.rb index 8387c23fe3f..c0bb257eda6 100644 --- a/app/services/merge_requests/remove_approval_service.rb +++ b/app/services/merge_requests/remove_approval_service.rb @@ -19,6 +19,7 @@ module MergeRequests merge_request_activity_counter.track_unapprove_mr_action(user: current_user) trigger_merge_request_merge_status_updated(merge_request) trigger_merge_request_reviewers_updated(merge_request) + trigger_merge_request_approval_state_updated(merge_request) end success diff --git a/app/services/metrics/dashboard/grafana_metric_embed_service.rb b/app/services/metrics/dashboard/grafana_metric_embed_service.rb index e94c8d92c3a..26ccded45f8 100644 --- a/app/services/metrics/dashboard/grafana_metric_embed_service.rb +++ b/app/services/metrics/dashboard/grafana_metric_embed_service.rb @@ -51,8 +51,7 @@ module Metrics # being passed to #get_dashboard (which accepts none) ::Metrics::Dashboard::BaseService .instance_method(:get_dashboard) - .bind(self) - .call() # rubocop:disable Style/MethodCallWithoutArgsParentheses + .bind_call(self) end def cache_key(*args) diff --git a/app/services/ml/experiment_tracking/candidate_repository.rb b/app/services/ml/experiment_tracking/candidate_repository.rb index b6f87995185..1dbeb30145b 100644 --- a/app/services/ml/experiment_tracking/candidate_repository.rb +++ b/app/services/ml/experiment_tracking/candidate_repository.rb @@ -14,11 +14,15 @@ module Ml ::Ml::Candidate.with_project_id_and_iid(project.id, iid) end - def create!(experiment, start_time) - experiment.candidates.create!( + def create!(experiment, start_time, tags = nil) + candidate = experiment.candidates.create!( user: user, start_time: start_time || 0 ) + + add_tags(candidate, tags) + + candidate end def update(candidate, status, end_time) @@ -41,36 +45,21 @@ module Ml candidate.params.create!(name: name, value: value) end - def add_metrics(candidate, metric_definitions) - return unless candidate.present? - - metrics = metric_definitions.map do |metric| - { - candidate_id: candidate.id, - name: metric[:key], - value: metric[:value], - tracked_at: metric[:timestamp], - step: metric[:step], - **timestamps - } - end + def add_tag!(candidate, name, value) + candidate.metadata.create!(name: name, value: value) + end - ::Ml::CandidateMetric.insert_all(metrics, returning: false) unless metrics.empty? + def add_metrics(candidate, metric_definitions) + extra_keys = { tracked_at: :timestamp, step: :step } + insert_many(candidate, metric_definitions, ::Ml::CandidateMetric, extra_keys) end def add_params(candidate, param_definitions) - return unless candidate.present? - - parameters = param_definitions.map do |p| - { - candidate_id: candidate.id, - name: p[:key], - value: p[:value], - **timestamps - } - end + insert_many(candidate, param_definitions, ::Ml::CandidateParam) + end - ::Ml::CandidateParam.insert_all(parameters, returning: false) unless parameters.empty? + def add_tags(candidate, tag_definitions) + insert_many(candidate, tag_definitions, ::Ml::CandidateMetadata) end private @@ -80,6 +69,22 @@ module Ml { created_at: current_time, updated_at: current_time } end + + def insert_many(candidate, definitions, entity_class, extra_keys = {}) + return unless candidate.present? && definitions.present? + + entities = definitions.map do |d| + { + candidate_id: candidate.id, + name: d[:key], + value: d[:value], + **extra_keys.transform_values { |old_key| d[old_key] }, + **timestamps + } + end + + entity_class.insert_all(entities, returning: false) unless entities.empty? + end end end end diff --git a/app/services/ml/experiment_tracking/experiment_repository.rb b/app/services/ml/experiment_tracking/experiment_repository.rb index 891674adc2a..90f4cf1abec 100644 --- a/app/services/ml/experiment_tracking/experiment_repository.rb +++ b/app/services/ml/experiment_tracking/experiment_repository.rb @@ -20,10 +20,43 @@ module Ml ::Ml::Experiment.by_project_id(project.id) end - def create!(name) - ::Ml::Experiment.create!(name: name, - user: user, - project: project) + def create!(name, tags = nil) + experiment = ::Ml::Experiment.create!(name: name, + user: user, + project: project) + + add_tags(experiment, tags) + + experiment + end + + def add_tag!(experiment, key, value) + return unless experiment.present? + + experiment.metadata.create!(name: key, value: value) + end + + private + + def timestamps + current_time = Time.zone.now + + { created_at: current_time, updated_at: current_time } + end + + def add_tags(experiment, tag_definitions) + return unless experiment.present? && tag_definitions.present? + + entities = tag_definitions.map do |d| + { + experiment_id: experiment.id, + name: d[:key], + value: d[:value], + **timestamps + } + end + + ::Ml::ExperimentMetadata.insert_all(entities, returning: false) unless entities.empty? end end end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 660d9891e46..550bd6d4c55 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -98,10 +98,10 @@ class NotificationService end # Notify the user when one of their personal access tokens is revoked - def access_token_revoked(user, token_name) + def access_token_revoked(user, token_name, source = nil) return unless user.can?(:receive_notifications) - mailer.access_token_revoked_email(user, token_name).deliver_later + mailer.access_token_revoked_email(user, token_name, source).deliver_later end # Notify the user when at least one of their ssh key has expired today @@ -495,13 +495,7 @@ class NotificationService def new_access_request(member) return true unless member.notifiable?(:subscription) - source = member.source - - recipients = source.access_request_approvers_to_be_notified - - if fallback_to_group_access_request_approvers?(recipients, source) - recipients = source.group.access_request_approvers_to_be_notified - end + recipients = member.source.access_request_approvers_to_be_notified return true if recipients.empty? @@ -959,12 +953,6 @@ class NotificationService mailer.member_access_requested_email(member.real_source_type, member.id, recipient.user.id).deliver_later end - def fallback_to_group_access_request_approvers?(recipients, source) - return false if recipients.present? - - source.respond_to?(:group) && source.group - end - def warn_skipping_notifications(user, object) Gitlab::AppLogger.warn(message: "Skipping sending notifications", user: user.id, klass: object.class.to_s, object_id: object.id) end diff --git a/app/services/packages/debian/process_package_file_service.rb b/app/services/packages/debian/process_package_file_service.rb new file mode 100644 index 00000000000..59e8ac3425b --- /dev/null +++ b/app/services/packages/debian/process_package_file_service.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module Packages + module Debian + class ProcessPackageFileService + include ExclusiveLeaseGuard + include Gitlab::Utils::StrongMemoize + + SOURCE_FIELD_SPLIT_REGEX = /[ ()]/.freeze + # used by ExclusiveLeaseGuard + DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze + + def initialize(package_file, creator, distribution_name, component_name) + @package_file = package_file + @creator = creator + @distribution_name = distribution_name + @component_name = component_name + end + + def execute + try_obtain_lease do + validate! + + @package_file.transaction do + update_file_metadata + end + + ::Packages::Debian::GenerateDistributionWorker.perform_async(:project, package.debian_distribution.id) + end + end + + private + + def validate! + raise ArgumentError, 'package file without Debian metadata' unless @package_file.debian_file_metadatum + raise ArgumentError, 'already processed package file' unless @package_file.debian_file_metadatum.unknown? + + return if file_metadata[:file_type] == :deb || file_metadata[:file_type] == :udeb + + raise ArgumentError, "invalid package file type: #{file_metadata[:file_type]}" + end + + def update_file_metadata + ::Packages::UpdatePackageFileService.new(@package_file, package_id: package.id) + .execute + + # Force reload from database, as package has changed + @package_file.reload_package + + @package_file.debian_file_metadatum.update!( + file_type: file_metadata[:file_type], + component: @component_name, + architecture: file_metadata[:architecture], + fields: file_metadata[:fields] + ) + end + + def package + strong_memoize(:package) do + package_name = file_metadata[:fields]['Package'] + package_version = file_metadata[:fields]['Version'] + + if file_metadata[:fields]['Source'] + # "sample" or "sample (1.2.3~alpha2)" + source_field_parts = file_metadata[:fields]['Source'].split(SOURCE_FIELD_SPLIT_REGEX) + package_name = source_field_parts[0] + package_version = source_field_parts[2] || package_version + end + + params = { + 'name': package_name, + 'version': package_version, + 'distribution_name': @distribution_name + } + response = Packages::Debian::FindOrCreatePackageService.new(project, @creator, params).execute + response.payload[:package] + end + end + + def file_metadata + strong_memoize(:metadata) do + ::Packages::Debian::ExtractMetadataService.new(@package_file).execute + end + end + + def project + @package_file.package.project + end + + # used by ExclusiveLeaseGuard + def lease_key + "packages:debian:process_package_file_service:package_file:#{@package_file.id}" + end + + # used by ExclusiveLeaseGuard + def lease_timeout + DEFAULT_LEASE_TIMEOUT + end + end + end +end diff --git a/app/services/packages/rpm/parse_package_service.rb b/app/services/packages/rpm/parse_package_service.rb index 18b916a9d8b..d2751c77c5b 100644 --- a/app/services/packages/rpm/parse_package_service.rb +++ b/app/services/packages/rpm/parse_package_service.rb @@ -49,8 +49,8 @@ module Packages end def extract_static_attributes - STATIC_ATTRIBUTES.each_with_object({}) do |attribute, hash| - hash[attribute] = package_tags[attribute] + STATIC_ATTRIBUTES.index_with do |attribute| + package_tags[attribute] end end diff --git a/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb index ca5df4ce017..1733021cbb5 100644 --- a/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb +++ b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb @@ -28,7 +28,7 @@ module PagesDomains api_order = ::Gitlab::LetsEncrypt::Client.new.load_order(acme_order.url) - # https://tools.ietf.org/html/rfc8555#section-7.1.6 - statuses diagram + # https://www.rfc-editor.org/rfc/rfc8555#section-7.1.6 - statuses diagram case api_order.status when 'ready' api_order.request_certificate(private_key: acme_order.private_key, domain: pages_domain.domain) diff --git a/app/services/pages_domains/retry_acme_order_service.rb b/app/services/pages_domains/retry_acme_order_service.rb index ef3d8ce0b67..6251c9d3615 100644 --- a/app/services/pages_domains/retry_acme_order_service.rb +++ b/app/services/pages_domains/retry_acme_order_service.rb @@ -15,7 +15,26 @@ module PagesDomains pages_domain.update!(auto_ssl_failed: false) end - PagesDomainSslRenewalWorker.perform_async(pages_domain.id) if updated + return unless updated + + PagesDomainSslRenewalWorker.perform_async(pages_domain.id) + + publish_event(pages_domain) + end + + private + + def publish_event(domain) + event = PagesDomainUpdatedEvent.new( + data: { + project_id: domain.project.id, + namespace_id: domain.project.namespace_id, + root_namespace_id: domain.project.root_namespace.id, + domain: domain.domain + } + ) + + Gitlab::EventStore.publish(event) end end end diff --git a/app/services/personal_access_tokens/revoke_service.rb b/app/services/personal_access_tokens/revoke_service.rb index 5371b6c91ef..bb5edc27340 100644 --- a/app/services/personal_access_tokens/revoke_service.rb +++ b/app/services/personal_access_tokens/revoke_service.rb @@ -4,10 +4,13 @@ module PersonalAccessTokens class RevokeService < BaseService attr_reader :token, :current_user, :group - def initialize(current_user = nil, token: nil, group: nil) + VALID_SOURCES = %w[secret_detection].freeze + + def initialize(current_user = nil, token: nil, group: nil, source: nil) @current_user = current_user @token = token @group = group + @source = source end def execute @@ -15,7 +18,7 @@ module PersonalAccessTokens if token.revoke! log_event - notification_service.access_token_revoked(token.user, token.name) + notification_service.access_token_revoked(token.user, token.name, @source) ServiceResponse.success(message: success_message) else ServiceResponse.error(message: error_message) @@ -33,11 +36,24 @@ module PersonalAccessTokens end def revocation_permitted? - Ability.allowed?(current_user, :revoke_token, token) + if current_user + Ability.allowed?(current_user, :revoke_token, token) + else + source && VALID_SOURCES.include?(source) + end + end + + def source + current_user&.username || @source end def log_event - Gitlab::AppLogger.info("PAT REVOCATION: revoked_by: '#{current_user.username}', revoked_for: '#{token.user.username}', token_id: '#{token.id}'") + Gitlab::AppLogger.info( + class: self.class.name, + message: "PAT Revoked", + revoked_by: source, + revoked_for: token.user.username, + token_id: token.id) end end end diff --git a/app/services/projects/batch_forks_count_service.rb b/app/services/projects/batch_forks_count_service.rb index 6467744a435..78663d8dad5 100644 --- a/app/services/projects/batch_forks_count_service.rb +++ b/app/services/projects/batch_forks_count_service.rb @@ -7,11 +7,9 @@ module Projects class BatchForksCountService < Projects::BatchCountService # rubocop: disable CodeReuse/ActiveRecord def global_count - @global_count ||= begin - count_service.query(project_ids) + @global_count ||= count_service.query(project_ids) .group(:forked_from_project_id) .count - end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/projects/batch_open_issues_count_service.rb b/app/services/projects/batch_open_issues_count_service.rb index d6ff2291af8..c396d7c0cfc 100644 --- a/app/services/projects/batch_open_issues_count_service.rb +++ b/app/services/projects/batch_open_issues_count_service.rb @@ -7,9 +7,7 @@ module Projects class BatchOpenIssuesCountService < Projects::BatchCountService # rubocop: disable CodeReuse/ActiveRecord def global_count - @global_count ||= begin - count_service.query(project_ids).group(:project_id).count - end + @global_count ||= count_service.query(project_ids).group(:project_id).count end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/projects/container_repository/cleanup_tags_base_service.rb b/app/services/projects/container_repository/cleanup_tags_base_service.rb index 5393c2c080d..45557d03502 100644 --- a/app/services/projects/container_repository/cleanup_tags_base_service.rb +++ b/app/services/projects/container_repository/cleanup_tags_base_service.rb @@ -6,6 +6,8 @@ module Projects private def filter_out_latest!(tags) + return unless keep_latest + tags.reject!(&:latest?) end @@ -84,6 +86,10 @@ module Projects params['keep_n'] end + def keep_latest + params.fetch('keep_latest', true) + end + def project container_repository.project end diff --git a/app/services/projects/container_repository/destroy_service.rb b/app/services/projects/container_repository/destroy_service.rb index 83bb8624bba..6db6b449671 100644 --- a/app/services/projects/container_repository/destroy_service.rb +++ b/app/services/projects/container_repository/destroy_service.rb @@ -3,12 +3,46 @@ module Projects module ContainerRepository class DestroyService < BaseService - def execute(container_repository) + CLEANUP_TAGS_SERVICE_PARAMS = { + 'name_regex_delete' => '.*', + 'container_expiration_policy' => true, # to avoid permissions checks + 'keep_latest' => false + }.freeze + + def execute(container_repository, disable_timeout: true) return false unless can?(current_user, :update_container_image, project) # Delete tags outside of the transaction to avoid hitting an idle-in-transaction timeout - container_repository.delete_tags! - container_repository.delete_failed! unless container_repository.destroy + unless delete_tags(container_repository, disable_timeout) && + destroy_container_repository(container_repository) + container_repository.delete_failed! + end + end + + private + + def delete_tags(container_repository, disable_timeout) + service = Projects::ContainerRepository::CleanupTagsService.new( + container_repository: container_repository, + params: CLEANUP_TAGS_SERVICE_PARAMS.merge('disable_timeout' => disable_timeout) + ) + result = service.execute + return true if result[:status] == :success + + log_error(error_message(container_repository, 'error in deleting tags')) + false + end + + def destroy_container_repository(container_repository) + return true if container_repository.destroy + + log_error(error_message(container_repository, container_repository.errors.full_messages.join('. '))) + false + end + + def error_message(container_repository, message) + "Container repository with ID: #{container_repository.id} and path: #{container_repository.path}" \ + " failed with message: #{message}" end end end diff --git a/app/services/projects/container_repository/gitlab/cleanup_tags_service.rb b/app/services/projects/container_repository/gitlab/cleanup_tags_service.rb index e947e9575e2..b69a3cc1a2c 100644 --- a/app/services/projects/container_repository/gitlab/cleanup_tags_service.rb +++ b/app/services/projects/container_repository/gitlab/cleanup_tags_service.rb @@ -18,7 +18,7 @@ module Projects container_repository.each_tags_page(page_size: TAGS_PAGE_SIZE) do |tags| execute_for_tags(tags, result) - raise TimeoutError if timeout?(start_time) + raise TimeoutError if !timeout_disabled? && timeout?(start_time) end end end @@ -72,6 +72,10 @@ module Projects def pushed_at(tag) tag.updated_at || tag.created_at end + + def timeout_disabled? + params['disable_timeout'] || false + end end end end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index c72f9b4b602..a4b473f35c6 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -317,6 +317,3 @@ module Projects end Projects::CreateService.prepend_mod_with('Projects::CreateService') - -# Measurable should be at the bottom of the ancestor chain, so it will measure execution of EE::Projects::CreateService as well -Projects::CreateService.prepend(Measurable) diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb index ddbcfbb675c..a1f55f547a1 100644 --- a/app/services/projects/import_export/export_service.rb +++ b/app/services/projects/import_export/export_service.rb @@ -3,8 +3,6 @@ module Projects module ImportExport class ExportService < BaseService - prepend Measurable - def initialize(*args) super diff --git a/app/services/projects/import_export/parallel_export_service.rb b/app/services/projects/import_export/parallel_export_service.rb new file mode 100644 index 00000000000..7e4c0279b06 --- /dev/null +++ b/app/services/projects/import_export/parallel_export_service.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Projects + module ImportExport + class ParallelExportService + def initialize(export_job, current_user, after_export_strategy) + @export_job = export_job + @current_user = current_user + @after_export_strategy = after_export_strategy + @shared = project.import_export_shared + @logger = Gitlab::Export::Logger.build + end + + def execute + log_info('Parallel project export started') + + if save_exporters && save_export_archive + log_info('Parallel project export finished successfully') + execute_after_export_action(after_export_strategy) + else + notify_error + end + + ensure + cleanup + end + + private + + attr_reader :export_job, :current_user, :after_export_strategy, :shared, :logger + + delegate :project, to: :export_job + + def execute_after_export_action(after_export_strategy) + return if after_export_strategy.execute(current_user, project) + + notify_error + end + + def exporters + [version_saver, exported_relations_merger] + end + + def save_exporters + exporters.all? do |exporter| + log_info("Parallel project export - #{exporter.class.name} saver started") + + exporter.save + end + end + + def save_export_archive + Gitlab::ImportExport::Saver.save(exportable: project, shared: shared) + end + + def version_saver + @version_saver ||= Gitlab::ImportExport::VersionSaver.new(shared: shared) + end + + def exported_relations_merger + @relation_saver ||= Gitlab::ImportExport::Project::ExportedRelationsMerger.new( + export_job: export_job, + shared: shared) + end + + def cleanup + FileUtils.rm_rf(shared.export_path) if File.exist?(shared.export_path) + FileUtils.rm_rf(shared.archive_path) if File.exist?(shared.archive_path) + end + + def log_info(message) + logger.info( + message: message, + **log_base_data + ) + end + + def notify_error + logger.error( + message: 'Parallel project export error', + export_errors: shared.errors.join(', '), + export_job_id: export_job.id, + **log_base_data + ) + + NotificationService.new.project_not_exported(project, current_user, shared.errors) + end + + def log_base_data + { + project_id: project.id, + project_name: project.name, + project_path: project.full_path + } + end + end + end +end diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index 6a13b8e38c1..967a1e990b2 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -179,6 +179,3 @@ module Projects end Projects::ImportService.prepend_mod_with('Projects::ImportService') - -# Measurable should be at the bottom of the ancestor chain, so it will measure execution of EE::Projects::ImportService as well -Projects::ImportService.prepend(Measurable) diff --git a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb index c91103f897f..f7de7f98768 100644 --- a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb +++ b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# This service lists the download link from a remote source based on the +# This service yields operation on each download link from a remote source based on the # oids provided module Projects module LfsPointers @@ -23,29 +23,22 @@ module Projects @remote_uri = remote_uri end - # This method accepts two parameters: # - oids: hash of oids to query. The structure is { lfs_file_oid => lfs_file_size } - # - # Returns an array of LfsDownloadObject - def execute(oids) - return [] unless project&.lfs_enabled? && remote_uri && oids.present? + # Yields operation for each link batch-by-batch + def each_link(oids, &block) + return unless project&.lfs_enabled? && remote_uri && oids.present? - get_download_links_in_batches(oids) + download_links_in_batches(oids, &block) end private - def get_download_links_in_batches(oids, batch_size = REQUEST_BATCH_SIZE) - download_links = [] - + def download_links_in_batches(oids, batch_size = REQUEST_BATCH_SIZE, &block) oids.each_slice(batch_size) do |batch| - download_links += get_download_links(batch) + download_links_for(batch).each(&block) end - - download_links - rescue DownloadLinksRequestEntityTooLargeError => e - # Log this exceptions to see how open it happens + # Log this exceptions to see how often it happens Gitlab::ErrorTracking .track_exception(e, project_id: project&.id, batch_size: batch_size, oids_count: oids.count) @@ -57,7 +50,7 @@ module Projects raise DownloadLinksError, 'Unable to download due to RequestEntityTooLarge errors' end - def get_download_links(oids) + def download_links_for(oids) response = Gitlab::HTTP.post(remote_uri, body: request_body(oids), headers: headers) diff --git a/app/services/projects/lfs_pointers/lfs_download_service.rb b/app/services/projects/lfs_pointers/lfs_download_service.rb index eaf73b78c1c..26352198e5c 100644 --- a/app/services/projects/lfs_pointers/lfs_download_service.rb +++ b/app/services/projects/lfs_pointers/lfs_download_service.rb @@ -92,9 +92,15 @@ module Projects end def fetch_file(&block) + attempts ||= 1 response = Gitlab::HTTP.get(lfs_sanitized_url, download_options, &block) raise ResponseError, "Received error code #{response.code}" unless response.success? + rescue Net::OpenTimeout + raise if attempts >= 3 + + attempts += 1 + retry end def with_tmp_file diff --git a/app/services/projects/lfs_pointers/lfs_import_service.rb b/app/services/projects/lfs_pointers/lfs_import_service.rb index 3fc82f2c410..c9791041088 100644 --- a/app/services/projects/lfs_pointers/lfs_import_service.rb +++ b/app/services/projects/lfs_pointers/lfs_import_service.rb @@ -9,9 +9,7 @@ module Projects def execute return success unless project&.lfs_enabled? - lfs_objects_to_download = LfsObjectDownloadListService.new(project).execute - - lfs_objects_to_download.each do |lfs_download_object| + LfsObjectDownloadListService.new(project).each_list_item do |lfs_download_object| LfsDownloadService.new(project, lfs_download_object).execute end diff --git a/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb b/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb index b4872cd9442..09fec9939b9 100644 --- a/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb +++ b/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -# This service manages the whole worflow of discovering the Lfs files in a -# repository, linking them to the project and downloading (and linking) the non -# existent ones. +# This service discovers the Lfs files that are linked in repository, +# but not downloaded yet and yields the operation +# on each Lfs file link (url) to remote repository. module Projects module LfsPointers class LfsObjectDownloadListService < BaseService @@ -14,30 +14,31 @@ module Projects LfsObjectDownloadListError = Class.new(StandardError) - def execute - return [] unless project&.lfs_enabled? - - if external_lfs_endpoint? - # If the endpoint host is different from the import_url it means - # that the repo is using a third party service for storing the LFS files. - # In this case, we have to disable lfs in the project - disable_lfs! - - return [] - end + def each_list_item(&block) + return unless context_valid? # Downloading the required information and gathering it inside an # LfsDownloadObject for each oid - # LfsDownloadLinkListService .new(project, remote_uri: current_endpoint_uri) - .execute(missing_lfs_files) + .each_link(missing_lfs_files, &block) rescue LfsDownloadLinkListService::DownloadLinksError => e raise LfsObjectDownloadListError, "The LFS objects download list couldn't be imported. Error: #{e.message}" end private + def context_valid? + return false unless project&.lfs_enabled? + return true unless external_lfs_endpoint? + + # If the endpoint host is different from the import_url it means + # that the repo is using a third party service for storing the LFS files. + # In this case, we have to disable lfs in the project + disable_lfs! + false + end + def external_lfs_endpoint? lfsconfig_endpoint_uri && lfsconfig_endpoint_uri.host != import_uri.host end @@ -99,12 +100,10 @@ module Projects # The import url must end with '.git' here we ensure it is def default_endpoint_uri - @default_endpoint_uri ||= begin - import_uri.dup.tap do |uri| - path = uri.path.gsub(%r(/$), '') - path += '.git' unless path.ends_with?('.git') - uri.path = path + LFS_BATCH_API_ENDPOINT - end + @default_endpoint_uri ||= import_uri.dup.tap do |uri| + path = uri.path.gsub(%r(/$), '') + path += '.git' unless path.ends_with?('.git') + uri.path = path + LFS_BATCH_API_ENDPOINT end end end diff --git a/app/services/projects/refresh_build_artifacts_size_statistics_service.rb b/app/services/projects/refresh_build_artifacts_size_statistics_service.rb index 1f86e5f4ba9..8e006dc8c34 100644 --- a/app/services/projects/refresh_build_artifacts_size_statistics_service.rb +++ b/app/services/projects/refresh_build_artifacts_size_statistics_service.rb @@ -18,7 +18,7 @@ module Projects # Mark the refresh ready for another worker to pick up and process the next batch refresh.requeue!(batch.last.id) - refresh.project.statistics.delayed_increment_counter(:build_artifacts_size, total_artifacts_size) + refresh.project.statistics.increment_counter(:build_artifacts_size, total_artifacts_size) end else # Remove the refresh job from the table if there are no more diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index 6a963e7fcd1..0fadd75669e 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -63,16 +63,19 @@ module Projects end def build_commit_status + stage = create_stage + GenericCommitStatus.new( user: build.user, ci_stage: stage, name: 'pages:deploy', - stage: 'deploy' + stage: 'deploy', + stage_idx: stage.position ) end # rubocop: disable Performance/ActiveRecordSubtransactionMethods - def stage + def create_stage build.pipeline.stages.safe_find_or_create_by(name: 'deploy', pipeline_id: build.pipeline.id) do |stage| stage.position = GenericCommitStatus::EXTERNAL_STAGE_IDX stage.project = build.project diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb index f686f14b5b3..aca6fa91eb1 100644 --- a/app/services/projects/update_remote_mirror_service.rb +++ b/app/services/projects/update_remote_mirror_service.rb @@ -10,7 +10,7 @@ module Projects return success unless remote_mirror.enabled? # Blocked URLs are a hard failure, no need to attempt to retry - if Gitlab::UrlBlocker.blocked_url?(normalized_url(remote_mirror.url)) + if Gitlab::UrlBlocker.blocked_url?(normalized_url(remote_mirror.url), schemes: Project::VALID_MIRROR_PROTOCOLS) hard_retry_or_fail(remote_mirror, _('The remote mirror URL is invalid.'), tries) return error(remote_mirror.last_error) end diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index f9a2c825608..301d11d841c 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -10,7 +10,6 @@ module Projects def execute build_topics remove_unallowed_params - mirror_operations_access_level_changes validate! ensure_wiki_exists if enabling_wiki? @@ -65,16 +64,36 @@ module Projects return unless changing_default_branch? previous_default_branch = project.default_branch + new_default_branch = params[:default_branch] - if project.change_head(params[:default_branch]) + if project.change_head(new_default_branch) params[:previous_default_branch] = previous_default_branch + if !project.root_ref?(new_default_branch) && has_custom_head_branch? + raise ValidationError, + format( + s_("UpdateProject|Could not set the default branch. Do you have a branch named 'HEAD' in your repository? (%{linkStart}How do I fix this?%{linkEnd})"), + linkStart: ambiguous_head_documentation_link, linkEnd: '</a>' + ).html_safe + end + after_default_branch_change(previous_default_branch) else raise ValidationError, s_("UpdateProject|Could not set the default branch") end end + def ambiguous_head_documentation_link + url = Rails.application.routes.url_helpers.help_page_path('user/project/repository/branches/index.md', anchor: 'error-ambiguous-head-branch-exists') + + format('<a href="%{url}" target="_blank" rel="noopener noreferrer">', url: url) + end + + # See issue: https://gitlab.com/gitlab-org/gitlab/-/issues/381731 + def has_custom_head_branch? + project.repository.branch_names.any? { |name| name.casecmp('head') == 0 } + end + def after_default_branch_change(previous_default_branch) # overridden by EE module end @@ -83,21 +102,6 @@ module Projects params.delete(:emails_disabled) unless can?(current_user, :set_emails_disabled, project) end - # Temporary code to sync permissions changes as operations access setting - # is being split into monitor_access_level, deployments_access_level, infrastructure_access_level. - # To be removed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/364240 - def mirror_operations_access_level_changes - return if Feature.enabled?(:split_operations_visibility_permissions, project) - - operations_access_level = params.dig(:project_feature_attributes, :operations_access_level) - - return if operations_access_level.nil? - - [:monitor_access_level, :infrastructure_access_level, :feature_flags_access_level, :environments_access_level].each do |key| - params[:project_feature_attributes][key] = operations_access_level - end - end - def after_update todos_features_changes = %w( issues_access_level diff --git a/app/services/protected_branches/api_service.rb b/app/services/protected_branches/api_service.rb index b8fe9bac13e..0a7777c7fed 100644 --- a/app/services/protected_branches/api_service.rb +++ b/app/services/protected_branches/api_service.rb @@ -3,11 +3,11 @@ module ProtectedBranches class ApiService < ProtectedBranches::BaseService def create - ::ProtectedBranches::CreateService.new(@project, @current_user, protected_branch_params).execute + ::ProtectedBranches::CreateService.new(project_or_group, @current_user, protected_branch_params).execute end def update(protected_branch) - ::ProtectedBranches::UpdateService.new(@project, @current_user, + ::ProtectedBranches::UpdateService.new(project_or_group, @current_user, protected_branch_params(with_defaults: false)).execute(protected_branch) end @@ -36,4 +36,4 @@ protected_branch_params(with_defaults: false)).execute(protected_branch) end end -ProtectedBranches::ApiService.prepend_mod_with('ProtectedBranches::ApiService') +ProtectedBranches::ApiService.prepend_mod diff --git a/app/services/protected_branches/base_service.rb b/app/services/protected_branches/base_service.rb index d26c1b148bf..951017b2d01 100644 --- a/app/services/protected_branches/base_service.rb +++ b/app/services/protected_branches/base_service.rb @@ -2,10 +2,12 @@ module ProtectedBranches class BaseService < ::BaseService + attr_reader :project_or_group + # current_user - The user that performs the action # params - A hash of parameters - def initialize(project, current_user = nil, params = {}) - @project = project + def initialize(project_or_group, current_user = nil, params = {}) + @project_or_group = project_or_group @current_user = current_user @params = params end @@ -15,7 +17,7 @@ module ProtectedBranches end def refresh_cache - CacheService.new(@project, @current_user, @params).refresh + CacheService.new(@project_or_group, @current_user, @params).refresh end end end diff --git a/app/services/protected_branches/cache_service.rb b/app/services/protected_branches/cache_service.rb index 66ca549c508..af8c9ce74bb 100644 --- a/app/services/protected_branches/cache_service.rb +++ b/app/services/protected_branches/cache_service.rb @@ -66,13 +66,18 @@ module ProtectedBranches log_error( 'class' => self.class.name, 'message' => "Cache mismatch '#{encoded_ref_name}': cached value: #{cached_value}, real value: #{real_value}", - 'project_id' => @project.id, - 'project_path' => @project.full_path + 'record_class' => project_or_group.class.name, + 'record_id' => project_or_group.id, + 'record_path' => project_or_group.full_path ) end def redis_key - @redis_key ||= [CACHE_ROOT_KEY, @project.id].join(':') + @redis_key ||= if Feature.enabled?(:group_protected_branches) + [CACHE_ROOT_KEY, project_or_group.class.name, project_or_group.id].join(':') + else + [CACHE_ROOT_KEY, project_or_group.id].join(':') + end end def metrics diff --git a/app/services/protected_branches/create_service.rb b/app/services/protected_branches/create_service.rb index 903addf7afc..46585e0b65d 100644 --- a/app/services/protected_branches/create_service.rb +++ b/app/services/protected_branches/create_service.rb @@ -23,9 +23,9 @@ module ProtectedBranches end def protected_branch - @protected_branch ||= project.protected_branches.new(params) + @protected_branch ||= project_or_group.protected_branches.new(params) end end end -ProtectedBranches::CreateService.prepend_mod_with('ProtectedBranches::CreateService') +ProtectedBranches::CreateService.prepend_mod diff --git a/app/services/protected_branches/destroy_service.rb b/app/services/protected_branches/destroy_service.rb index 01d3b68314f..a32a867491e 100644 --- a/app/services/protected_branches/destroy_service.rb +++ b/app/services/protected_branches/destroy_service.rb @@ -10,4 +10,4 @@ module ProtectedBranches end end -ProtectedBranches::DestroyService.prepend_mod_with('ProtectedBranches::DestroyService') +ProtectedBranches::DestroyService.prepend_mod diff --git a/app/services/protected_branches/legacy_api_create_service.rb b/app/services/protected_branches/legacy_api_create_service.rb index aef99a860a0..f662d9d1bf0 100644 --- a/app/services/protected_branches/legacy_api_create_service.rb +++ b/app/services/protected_branches/legacy_api_create_service.rb @@ -24,7 +24,7 @@ module ProtectedBranches @params.merge!(push_access_levels_attributes: [{ access_level: push_access_level }], merge_access_levels_attributes: [{ access_level: merge_access_level }]) - service = ProtectedBranches::CreateService.new(@project, @current_user, @params) + service = ProtectedBranches::CreateService.new(project_or_group, @current_user, @params) service.execute end end diff --git a/app/services/protected_branches/legacy_api_update_service.rb b/app/services/protected_branches/legacy_api_update_service.rb index 8ff6c4bd734..b144797ab6d 100644 --- a/app/services/protected_branches/legacy_api_update_service.rb +++ b/app/services/protected_branches/legacy_api_update_service.rb @@ -30,7 +30,7 @@ module ProtectedBranches params[:merge_access_levels_attributes] = [{ access_level: Gitlab::Access::MAINTAINER }] end - service = ProtectedBranches::UpdateService.new(project, current_user, params) + service = ProtectedBranches::UpdateService.new(project_or_group, current_user, params) service.execute(protected_branch) end end diff --git a/app/services/protected_branches/update_service.rb b/app/services/protected_branches/update_service.rb index c155e0022f5..4b54bf92989 100644 --- a/app/services/protected_branches/update_service.rb +++ b/app/services/protected_branches/update_service.rb @@ -19,4 +19,4 @@ module ProtectedBranches end end -ProtectedBranches::UpdateService.prepend_mod_with('ProtectedBranches::UpdateService') +ProtectedBranches::UpdateService.prepend_mod diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb index 1d7c5d2c80a..f1e4dac8835 100644 --- a/app/services/quick_actions/interpret_service.rb +++ b/app/services/quick_actions/interpret_service.rb @@ -158,15 +158,15 @@ module QuickActions end def map_commands(commands, method) - commands.map do |name, arg| - definition = self.class.definition_by_name(name) + commands.map do |name_or_alias, arg| + definition = self.class.definition_by_name(name_or_alias) next unless definition case method when :explain definition.explain(self, arg) when :execute_message - @execution_message[name.to_sym] || definition.execute_message(self, arg) + @execution_message[definition.name.to_sym] || definition.execute_message(self, arg) end end.compact end diff --git a/app/services/repositories/housekeeping_service.rb b/app/services/repositories/housekeeping_service.rb index de80390e60b..e12d69807f2 100644 --- a/app/services/repositories/housekeeping_service.rb +++ b/app/services/repositories/housekeeping_service.rb @@ -84,7 +84,11 @@ module Repositories end def period_match? - [gc_period, full_repack_period, repack_period, PACK_REFS_PERIOD].any? { |period| pushes_since_gc % period == 0 } + if Feature.enabled?(:optimized_housekeeping) + pushes_since_gc % repack_period == 0 + else + [gc_period, full_repack_period, repack_period, PACK_REFS_PERIOD].any? { |period| pushes_since_gc % period == 0 } + end end def housekeeping_enabled? diff --git a/app/services/search_service.rb b/app/services/search_service.rb index f38522b9764..403a2f077b0 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -45,9 +45,9 @@ class SearchService end def show_snippets? - return @show_snippets if defined?(@show_snippets) - - @show_snippets = params[:snippets] == 'true' + strong_memoize(:show_snippets) do + params[:snippets] == 'true' + end end delegate :scope, to: :search_service diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb index 5cadff42958..a62d5290271 100644 --- a/app/services/snippets/create_service.rb +++ b/app/services/snippets/create_service.rb @@ -4,7 +4,7 @@ module Snippets class CreateService < Snippets::BaseService # NOTE: For Issues::CreateService, we require the spam_params and do not default it to nil, because # spam_checking is likely to be necessary. - def initialize(project:, current_user: nil, params: {}, spam_params:) + def initialize(project:, spam_params:, current_user: nil, params: {}) super(project: project, current_user: current_user, params: params) @spam_params = spam_params end diff --git a/app/services/system_notes/commit_service.rb b/app/services/system_notes/commit_service.rb index c89998f77c7..592351079aa 100644 --- a/app/services/system_notes/commit_service.rb +++ b/app/services/system_notes/commit_service.rb @@ -81,12 +81,10 @@ module SystemNotes commit_ids = if count == 1 existing_commits.first.short_id + elsif oldrev && !Gitlab::Git.blank_ref?(oldrev) + "#{Commit.truncate_sha(oldrev)}...#{existing_commits.last.short_id}" else - if oldrev && !Gitlab::Git.blank_ref?(oldrev) - "#{Commit.truncate_sha(oldrev)}...#{existing_commits.last.short_id}" - else - "#{existing_commits.first.short_id}..#{existing_commits.last.short_id}" - end + "#{existing_commits.first.short_id}..#{existing_commits.last.short_id}" end commits_text = "#{count} commit".pluralize(count) diff --git a/app/services/task_list_toggle_service.rb b/app/services/task_list_toggle_service.rb index 082fa1447fc..8e20ffc2a52 100644 --- a/app/services/task_list_toggle_service.rb +++ b/app/services/task_list_toggle_service.rb @@ -44,8 +44,8 @@ class TaskListToggleService # any `[ ]` or `[x]` in the middle of the text if currently_checked markdown_task.sub!(Taskable::COMPLETE_PATTERN, '[ ]') unless toggle_as_checked - else - markdown_task.sub!(Taskable::INCOMPLETE_PATTERN, '[x]') if toggle_as_checked + elsif toggle_as_checked + markdown_task.sub!(Taskable::INCOMPLETE_PATTERN, '[x]') end source_lines[source_line_index] = markdown_task diff --git a/app/services/timelogs/base_service.rb b/app/services/timelogs/base_service.rb index e09264864fd..712a0a4f128 100644 --- a/app/services/timelogs/base_service.rb +++ b/app/services/timelogs/base_service.rb @@ -22,9 +22,9 @@ module Timelogs end def error_in_save(timelog) - return error(_("Failed to save timelog")) if timelog.errors.empty? + return error(_("Failed to save timelog"), 404) if timelog.errors.empty? - error(timelog.errors.full_messages.to_sentence) + error(timelog.errors.full_messages.to_sentence, 404) end end end diff --git a/app/services/timelogs/create_service.rb b/app/services/timelogs/create_service.rb index 12181cec20a..19428864fa9 100644 --- a/app/services/timelogs/create_service.rb +++ b/app/services/timelogs/create_service.rb @@ -21,6 +21,9 @@ module Timelogs }, 404) end + return error(_("Spent at can't be a future date and time."), 404) if spent_at.future? + return error(_("Time spent can't be zero."), 404) if time_spent == 0 + issue = issuable if issuable.is_a?(Issue) merge_request = issuable if issuable.is_a?(MergeRequest) diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 06352d36215..9ae31f8ac58 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -166,8 +166,9 @@ class TodoService # When user marks a target as todo def mark_todo(target, current_user) - attributes = attributes_for_todo(target.project, target, current_user, Todo::MARKED) - create_todos(current_user, attributes) + project = target.project + attributes = attributes_for_todo(project, target, current_user, Todo::MARKED) + create_todos(current_user, attributes, project&.namespace, project) end def todo_exist?(issuable, current_user) @@ -214,13 +215,32 @@ class TodoService end def create_request_review_todo(target, author, reviewers) - attributes = attributes_for_todo(target.project, target, author, Todo::REVIEW_REQUESTED) - create_todos(reviewers, attributes) + project = target.project + attributes = attributes_for_todo(project, target, author, Todo::REVIEW_REQUESTED) + create_todos(reviewers, attributes, project.namespace, project) + end + + def create_member_access_request(member) + source = member.source + attributes = attributes_for_access_request_todos(source, member.user, Todo::MEMBER_ACCESS_REQUESTED) + + approvers = source.access_request_approvers_to_be_notified.map(&:user) + return true if approvers.empty? + + if source.instance_of? Project + project = source + namespace = project.namespace + else + project = nil + namespace = source + end + + create_todos(approvers, attributes, namespace, project) end private - def create_todos(users, attributes) + def create_todos(users, attributes, namespace, project) users = Array(users) return if users.empty? @@ -246,7 +266,7 @@ class TodoService todos = users.map do |user| issue_type = attributes.delete(:issue_type) - track_todo_creation(user, issue_type) + track_todo_creation(user, issue_type, namespace, project) Todo.create(attributes.merge(user_id: user.id)) end @@ -286,9 +306,10 @@ class TodoService def create_assignment_todo(target, author, old_assignees = []) if target.assignees.any? + project = target.project assignees = target.assignees - old_assignees - attributes = attributes_for_todo(target.project, target, author, Todo::ASSIGNED) - create_todos(assignees, attributes) + attributes = attributes_for_todo(project, target, author, Todo::ASSIGNED) + create_todos(assignees, attributes, project.namespace, project) end end @@ -303,22 +324,24 @@ class TodoService # Create Todos for directly addressed users directly_addressed_users = filter_directly_addressed_users(parent, note || target, author, skip_users) attributes = attributes_for_todo(parent, target, author, Todo::DIRECTLY_ADDRESSED, note) - create_todos(directly_addressed_users, attributes) + create_todos(directly_addressed_users, attributes, parent&.namespace, parent) # Create Todos for mentioned users mentioned_users = filter_mentioned_users(parent, note || target, author, skip_users + directly_addressed_users) attributes = attributes_for_todo(parent, target, author, Todo::MENTIONED, note) - create_todos(mentioned_users, attributes) + create_todos(mentioned_users, attributes, parent&.namespace, parent) end def create_build_failed_todo(merge_request, todo_author) - attributes = attributes_for_todo(merge_request.project, merge_request, todo_author, Todo::BUILD_FAILED) - create_todos(todo_author, attributes) + project = merge_request.project + attributes = attributes_for_todo(project, merge_request, todo_author, Todo::BUILD_FAILED) + create_todos(todo_author, attributes, project.namespace, project) end def create_unmergeable_todo(merge_request, todo_author) - attributes = attributes_for_todo(merge_request.project, merge_request, todo_author, Todo::UNMERGEABLE) - create_todos(todo_author, attributes) + project = merge_request.project + attributes = attributes_for_todo(project, merge_request, todo_author, Todo::UNMERGEABLE) + create_todos(todo_author, attributes, project.namespace, project) end def attributes_for_target(target) @@ -382,10 +405,37 @@ class TodoService PendingTodosFinder.new(users, criteria).execute end - def track_todo_creation(user, issue_type) + def track_todo_creation(user, issue_type, namespace, project) return unless issue_type == 'incident' - track_usage_event(:incident_management_incident_todo, user.id) + event = "incident_management_incident_todo" + track_usage_event(event, user.id) + + return unless Feature.enabled?(:route_hll_to_snowplow_phase2, namespace) + + Gitlab::Tracking.event( + self.class.to_s, + event, + project: project, + namespace: namespace, + user: user, + label: 'redis_hll_counters.incident_management.incident_management_total_unique_counts_monthly', + context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: event).to_context] + ) + end + + def attributes_for_access_request_todos(source, author, action, note = nil) + attributes = { + target_id: source.id, + target_type: source.class.polymorphic_name, + author_id: author.id, + action: action, + note: note + } + + attributes[:group_id] = source.id unless source.instance_of? Project + + attributes end end diff --git a/app/services/users/approve_service.rb b/app/services/users/approve_service.rb index 15486ddcd43..353456c545d 100644 --- a/app/services/users/approve_service.rb +++ b/app/services/users/approve_service.rb @@ -42,7 +42,7 @@ module Users end def log_event(user) - Gitlab::AppLogger.info(message: "User instance access request approved", user: "#{user.username}", email: "#{user.email}", approved_by: "#{current_user.username}", ip_address: "#{current_user.current_sign_in_ip}") + Gitlab::AppLogger.info(message: "User instance access request approved", user: user.username.to_s, email: user.email.to_s, approved_by: current_user.username.to_s, ip_address: current_user.current_sign_in_ip.to_s) end end end diff --git a/app/services/users/assigned_issues_count_service.rb b/app/services/users/assigned_issues_count_service.rb new file mode 100644 index 00000000000..6590902587d --- /dev/null +++ b/app/services/users/assigned_issues_count_service.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Users + class AssignedIssuesCountService < ::BaseCountService + def initialize(current_user:, max_limit: User::MAX_LIMIT_FOR_ASSIGNEED_ISSUES_COUNT) + @current_user = current_user + @max_limit = max_limit + end + + def cache_key + ['users', @current_user.id, 'max_assigned_open_issues_count'] + end + + def cache_options + { force: false, expires_in: User::COUNT_CACHE_VALIDITY_PERIOD } + end + + # rubocop: disable CodeReuse/ActiveRecord + def uncached_count + # When a user has many assigned issues, counting them all can be very slow. + # As a workaround, we will short-circuit the counting query once the count reaches some threshold. + # + # Concretely, given a threshold, say 100 (= max_limit), + # iterate through the first 100 issues, sorted by ID desc, assigned to the user using `issue_assignees` table. + # For each issue iterated, use IssuesFinder to check if the issue should be counted. + initializer = IssueAssignee + .select(:issue_id).joins(", LATERAL (#{finder_constraint.to_sql}) as issues") + .where(user_id: @current_user.id) + .order(issue_id: :desc) + .limit(1) + recursive_finder = initializer.where("issue_assignees.issue_id < assigned_issues.issue_id") + + cte = <<~SQL + WITH RECURSIVE assigned_issues AS ( + ( + #{initializer.to_sql} + ) + UNION ALL + ( + SELECT next_assigned_issue.issue_id + FROM assigned_issues, + LATERAL ( + #{recursive_finder.to_sql} + ) next_assigned_issue + ) + ) SELECT COUNT(*) FROM (SELECT * FROM assigned_issues LIMIT #{@max_limit}) issues + SQL + + ApplicationRecord.connection.execute(cte).first["count"] + end + # rubocop: enable CodeReuse/ActiveRecord + + private + + # rubocop: disable CodeReuse/ActiveRecord + def finder_constraint + IssuesFinder.new(@current_user, assignee_id: @current_user.id, state: 'opened', non_archived: true) + .execute + .where("issues.id=issue_assignees.issue_id").limit(1) + end + # rubocop: enable CodeReuse/ActiveRecord + end +end diff --git a/app/services/users/banned_user_base_service.rb b/app/services/users/banned_user_base_service.rb index a582816283a..74c10581a6e 100644 --- a/app/services/users/banned_user_base_service.rb +++ b/app/services/users/banned_user_base_service.rb @@ -36,7 +36,7 @@ module Users end def log_event(user) - Gitlab::AppLogger.info(message: "User #{action}", user: "#{user.username}", email: "#{user.email}", "#{action}_by": "#{current_user.username}", ip_address: "#{current_user.current_sign_in_ip}") + Gitlab::AppLogger.info(message: "User #{action}", user: user.username.to_s, email: user.email.to_s, "#{action}_by": current_user.username.to_s, ip_address: current_user.current_sign_in_ip.to_s) end end end diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb index 8ef1b3e0613..064bf132d3d 100644 --- a/app/services/users/build_service.rb +++ b/app/services/users/build_service.rb @@ -117,7 +117,7 @@ module Users end def skip_user_confirmation_email_from_setting - !Gitlab::CurrentSettings.send_user_confirmation_email + Gitlab::CurrentSettings.email_confirmation_setting_off? end def use_fallback_name? diff --git a/app/services/users/keys_count_service.rb b/app/services/users/keys_count_service.rb index f82d27eded9..378093f2e1b 100644 --- a/app/services/users/keys_count_service.rb +++ b/app/services/users/keys_count_service.rb @@ -11,7 +11,7 @@ module Users end def relation_for_count - user.keys + user.keys.auth end def raw? diff --git a/app/services/users/migrate_records_to_ghost_user_service.rb b/app/services/users/migrate_records_to_ghost_user_service.rb index 2d92aaed7da..5d518803315 100644 --- a/app/services/users/migrate_records_to_ghost_user_service.rb +++ b/app/services/users/migrate_records_to_ghost_user_service.rb @@ -42,6 +42,7 @@ module Users migrate_award_emoji migrate_snippets migrate_reviews + migrate_releases end def post_migrate_records @@ -96,6 +97,10 @@ module Users batched_migrate(Review, :author_id) end + def migrate_releases + batched_migrate(Release, :author_id) + end + # rubocop:disable CodeReuse/ActiveRecord def batched_migrate(base_scope, column, batch_size: 50) loop do diff --git a/app/services/users/reject_service.rb b/app/services/users/reject_service.rb index 459dd81b74d..dc22b2ec21d 100644 --- a/app/services/users/reject_service.rb +++ b/app/services/users/reject_service.rb @@ -34,7 +34,7 @@ module Users end def log_event(user) - Gitlab::AppLogger.info(message: "User instance access request rejected", user: "#{user.username}", email: "#{user.email}", rejected_by: "#{current_user.username}", ip_address: "#{current_user.current_sign_in_ip}") + Gitlab::AppLogger.info(message: "User instance access request rejected", user: user.username.to_s, email: user.email.to_s, rejected_by: current_user.username.to_s, ip_address: current_user.current_sign_in_ip.to_s) end end end diff --git a/app/services/users/update_highest_member_role_service.rb b/app/services/users/update_highest_member_role_service.rb index 90a5966265d..fff001e04d7 100644 --- a/app/services/users/update_highest_member_role_service.rb +++ b/app/services/users/update_highest_member_role_service.rb @@ -17,9 +17,7 @@ module Users private def user_highest_role - @user_highest_role ||= begin - @user.user_highest_role || @user.build_user_highest_role - end + @user_highest_role ||= @user.user_highest_role || @user.build_user_highest_role end def highest_access_level diff --git a/app/services/web_hooks/log_execution_service.rb b/app/services/web_hooks/log_execution_service.rb index 448bb7d4097..b1da0c1642f 100644 --- a/app/services/web_hooks/log_execution_service.rb +++ b/app/services/web_hooks/log_execution_service.rb @@ -17,18 +17,32 @@ module WebHooks end def execute - update_hook_failure_state if WebHook.web_hooks_disable_failed?(hook) + update_hook_failure_state log_execution end private def log_execution + mask_response_headers + log_data[:request_headers]['X-Gitlab-Token'] = _('[REDACTED]') if hook.token? WebHookLog.create!(web_hook: hook, **log_data) end + def mask_response_headers + return unless hook.url_variables? + return unless log_data.key?(:response_headers) + + variables_map = hook.url_variables.invert.transform_values { "{#{_1}}" } + regex = Regexp.union(variables_map.keys) + + log_data[:response_headers].transform_values! do |value| + regex === value ? value.gsub(regex, variables_map) : value + end + end + # Perform this operation within an `Gitlab::ExclusiveLease` lock to make it # safe to be called concurrently from different workers. def update_hook_failure_state diff --git a/app/services/wiki_pages/update_service.rb b/app/services/wiki_pages/update_service.rb index 12b2cf87d5d..cf9eddbd13f 100644 --- a/app/services/wiki_pages/update_service.rb +++ b/app/services/wiki_pages/update_service.rb @@ -12,7 +12,7 @@ module WikiPages execute_hooks(page) ServiceResponse.success(payload: { page: page }) else - raise UpdateError, s_('Could not update wiki page') + raise UpdateError, _('Could not update wiki page') end rescue UpdateError, WikiPage::PageChangedError, WikiPage::PageRenameError => e page.update_attributes(@params) # rubocop:disable Rails/ActiveRecordAliases diff --git a/app/services/work_items/create_and_link_service.rb b/app/services/work_items/create_and_link_service.rb index 5cc358c4b4f..351ebc14564 100644 --- a/app/services/work_items/create_and_link_service.rb +++ b/app/services/work_items/create_and_link_service.rb @@ -6,7 +6,7 @@ module WorkItems # This class should always be run inside a transaction as we could end up with # new work items that were never associated with other work items as expected. class CreateAndLinkService - def initialize(project:, current_user: nil, params: {}, spam_params:, link_params: {}) + def initialize(project:, spam_params:, current_user: nil, params: {}, link_params: {}) @project = project @current_user = current_user @params = params diff --git a/app/services/work_items/create_from_task_service.rb b/app/services/work_items/create_from_task_service.rb index ef1d47c560d..ced5b17a21c 100644 --- a/app/services/work_items/create_from_task_service.rb +++ b/app/services/work_items/create_from_task_service.rb @@ -2,7 +2,7 @@ module WorkItems class CreateFromTaskService - def initialize(work_item:, current_user: nil, work_item_params: {}, spam_params:) + def initialize(work_item:, spam_params:, current_user: nil, work_item_params: {}) @work_item = work_item @current_user = current_user @work_item_params = work_item_params diff --git a/app/services/work_items/create_service.rb b/app/services/work_items/create_service.rb index 87cc690d666..c89ebc75b80 100644 --- a/app/services/work_items/create_service.rb +++ b/app/services/work_items/create_service.rb @@ -4,7 +4,7 @@ module WorkItems class CreateService < Issues::CreateService include WidgetableService - def initialize(project:, current_user: nil, params: {}, spam_params:, widget_params: {}) + def initialize(project:, spam_params:, current_user: nil, params: {}, widget_params: {}) super( project: project, current_user: current_user, diff --git a/app/services/work_items/delete_task_service.rb b/app/services/work_items/delete_task_service.rb index 3bb23576442..2a82a993b71 100644 --- a/app/services/work_items/delete_task_service.rb +++ b/app/services/work_items/delete_task_service.rb @@ -2,7 +2,7 @@ module WorkItems class DeleteTaskService - def initialize(work_item:, current_user: nil, task_params: {}, lock_version:) + def initialize(work_item:, lock_version:, current_user: nil, task_params: {}) @work_item = work_item @current_user = current_user @task_params = task_params diff --git a/app/uploaders/ci/secure_file_uploader.rb b/app/uploaders/ci/secure_file_uploader.rb index 8aa624d6b30..11cbfc6c1f2 100644 --- a/app/uploaders/ci/secure_file_uploader.rb +++ b/app/uploaders/ci/secure_file_uploader.rb @@ -34,10 +34,6 @@ module Ci false end - def background_upload_enabled? - false - end - def default_store object_store_enabled? ? ObjectStorage::Store::REMOTE : ObjectStorage::Store::LOCAL end diff --git a/app/uploaders/file_mover.rb b/app/uploaders/file_mover.rb index 95bc2680ed6..92ab2d88b41 100644 --- a/app/uploaders/file_mover.rb +++ b/app/uploaders/file_mover.rb @@ -24,7 +24,6 @@ class FileMover if update_markdown update_upload_model - uploader.schedule_background_upload end end diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb index 7250ce5c0b0..f947f70985c 100644 --- a/app/uploaders/file_uploader.rb +++ b/app/uploaders/file_uploader.rb @@ -27,10 +27,6 @@ class FileUploader < GitlabUploader after :remove, :prune_store_dir - # FileUploader do not run in a model transaction, so we can simply - # enqueue a job after the :store hook. - after :store, :schedule_background_upload - def self.root File.join(options.storage_path, 'uploads') end diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb index abe06bd97e1..62024bff4c0 100644 --- a/app/uploaders/gitlab_uploader.rb +++ b/app/uploaders/gitlab_uploader.rb @@ -101,8 +101,8 @@ class GitlabUploader < CarrierWave::Uploader::Base stream = if file_storage? File.open(path, "rb") if path - else - ::Gitlab::HttpIO.new(url, cached_size) if url + elsif url + ::Gitlab::HttpIO.new(url, cached_size) end return unless stream @@ -115,6 +115,15 @@ class GitlabUploader < CarrierWave::Uploader::Base end end + def multi_read(offsets) + open do |stream| + offsets.map do |start_offset, end_offset| + stream.seek(start_offset) + stream.read(end_offset - start_offset + 1) + end + end + end + # Used to replace an existing upload with another +file+ without modifying stored metadata # Use this method only to repair/replace an existing upload, or to upload to a Geo secondary node # diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb index 063aca7937c..e74998ce4a8 100644 --- a/app/uploaders/object_storage.rb +++ b/app/uploaders/object_storage.rb @@ -67,16 +67,6 @@ module ObjectStorage super end - def schedule_background_upload(*args) - return unless schedule_background_upload? - return unless upload - - ObjectStorage::BackgroundMoveWorker.perform_async(self.class.name, - upload.class.to_s, - mounted_as, - upload.id) - end - def exclusive_lease_key # For FileUploaders, model may have many uploaders. In that case # we want to use exclusive key per upload, not per model to allow @@ -99,40 +89,6 @@ module ObjectStorage end end - # Add support for automatic background uploading after the file is stored. - # - module BackgroundMove - extend ActiveSupport::Concern - - def background_upload(mount_points = []) - return unless mount_points.any? - - run_after_commit do - mount_points.each { |mount| send(mount).schedule_background_upload } # rubocop:disable GitlabSecurity/PublicSend - end - end - - def changed_mounts - self.class.uploaders.select do |mount, uploader_class| - mounted_as = uploader_class.serialization_column(self.class, mount) - uploader = send(:"#{mounted_as}") # rubocop:disable GitlabSecurity/PublicSend - - next unless uploader - next unless uploader.exists? - next unless send(:"saved_change_to_#{mounted_as}?") # rubocop:disable GitlabSecurity/PublicSend - - mount - end.keys - end - - included do - include AfterCommitQueue - after_save do - background_upload(changed_mounts) - end - end - end - module Concern extend ActiveSupport::Concern @@ -155,10 +111,6 @@ module ObjectStorage object_store_options&.direct_upload end - def background_upload_enabled? - object_store_options.background_upload - end - def proxy_download_enabled? object_store_options.proxy_download end @@ -311,15 +263,6 @@ module ObjectStorage end end - def schedule_background_upload(*args) - return unless schedule_background_upload? - - ObjectStorage::BackgroundMoveWorker.perform_async(self.class.name, - model.class.name, - mounted_as, - model.id) - end - def fog_directory self.class.remote_store_path end @@ -405,12 +348,6 @@ module ObjectStorage private - def schedule_background_upload? - self.class.object_store_enabled? && - self.class.background_upload_enabled? && - self.file_storage? - end - def cache_remote_file!(remote_object_id, original_filename) file_path = File.join(TMP_UPLOAD_PATH, remote_object_id) file_path = Pathname.new(file_path).cleanpath.to_s diff --git a/app/uploaders/packages/composer/cache_uploader.rb b/app/uploaders/packages/composer/cache_uploader.rb index f8052ec4810..ad7c017c4ba 100644 --- a/app/uploaders/packages/composer/cache_uploader.rb +++ b/app/uploaders/packages/composer/cache_uploader.rb @@ -4,8 +4,6 @@ class Packages::Composer::CacheUploader < GitlabUploader storage_options Gitlab.config.packages - after :store, :schedule_background_upload - alias_method :upload, :model def filename diff --git a/app/uploaders/packages/debian/component_file_uploader.rb b/app/uploaders/packages/debian/component_file_uploader.rb index e4d637fecac..2de4743d7f7 100644 --- a/app/uploaders/packages/debian/component_file_uploader.rb +++ b/app/uploaders/packages/debian/component_file_uploader.rb @@ -5,8 +5,6 @@ class Packages::Debian::ComponentFileUploader < GitlabUploader storage_options Gitlab.config.packages - after :store, :schedule_background_upload - alias_method :upload, :model def filename diff --git a/app/uploaders/packages/debian/distribution_release_file_uploader.rb b/app/uploaders/packages/debian/distribution_release_file_uploader.rb index a6ff3767b22..268d42796e9 100644 --- a/app/uploaders/packages/debian/distribution_release_file_uploader.rb +++ b/app/uploaders/packages/debian/distribution_release_file_uploader.rb @@ -5,8 +5,6 @@ class Packages::Debian::DistributionReleaseFileUploader < GitlabUploader storage_options Gitlab.config.packages - after :store, :schedule_background_upload - alias_method :upload, :model def filename diff --git a/app/uploaders/packages/package_file_uploader.rb b/app/uploaders/packages/package_file_uploader.rb index 9c0a88c9bf8..c8a09c50dc6 100644 --- a/app/uploaders/packages/package_file_uploader.rb +++ b/app/uploaders/packages/package_file_uploader.rb @@ -5,8 +5,6 @@ class Packages::PackageFileUploader < GitlabUploader storage_options Gitlab.config.packages - after :store, :schedule_background_upload - alias_method :upload, :model def filename diff --git a/app/uploaders/packages/rpm/repository_file_uploader.rb b/app/uploaders/packages/rpm/repository_file_uploader.rb index ff7e2bc719a..f95f861585c 100644 --- a/app/uploaders/packages/rpm/repository_file_uploader.rb +++ b/app/uploaders/packages/rpm/repository_file_uploader.rb @@ -6,8 +6,6 @@ module Packages storage_options Gitlab.config.packages - after :store, :schedule_background_upload - alias_method :upload, :model def filename diff --git a/app/uploaders/pages/deployment_uploader.rb b/app/uploaders/pages/deployment_uploader.rb index e510025fc7d..c5ba65673ab 100644 --- a/app/uploaders/pages/deployment_uploader.rb +++ b/app/uploaders/pages/deployment_uploader.rb @@ -36,13 +36,6 @@ module Pages false end - # we don't need background uploads because we upload files - # to the right store right away, and we already do that in - # the background job - def background_upload_enabled? - false - end - def default_store object_store_enabled? ? ObjectStorage::Store::REMOTE : ObjectStorage::Store::LOCAL end diff --git a/app/uploaders/terraform/state_uploader.rb b/app/uploaders/terraform/state_uploader.rb index 091b253b0ed..61e7ed7b0e6 100644 --- a/app/uploaders/terraform/state_uploader.rb +++ b/app/uploaders/terraform/state_uploader.rb @@ -48,10 +48,6 @@ module Terraform false end - def background_upload_enabled? - false - end - def proxy_download_enabled? true end diff --git a/app/validators/iso8601_date_validator.rb b/app/validators/iso8601_date_validator.rb new file mode 100644 index 00000000000..2b4682f0572 --- /dev/null +++ b/app/validators/iso8601_date_validator.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Iso8601DateValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + Date.iso8601(record.read_attribute_before_type_cast(attribute).to_s) + rescue ArgumentError, TypeError + record.errors.add(attribute, _('must be in ISO 8601 format')) + end +end diff --git a/app/validators/json_schemas/build_metadata_id_tokens.json b/app/validators/json_schemas/build_metadata_id_tokens.json index 7f39c7274f3..d97b2241ca3 100644 --- a/app/validators/json_schemas/build_metadata_id_tokens.json +++ b/app/validators/json_schemas/build_metadata_id_tokens.json @@ -5,18 +5,27 @@ "patternProperties": { ".*": { "type": "object", - "patternProperties": { - "^id_token$": { - "type": "object", - "required": ["aud"], - "properties": { - "aud": { "type": "string" }, - "field": { "type": "string" } - }, - "additionalProperties": false + "required": [ + "aud" + ], + "properties": { + "aud": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + } + ] } }, "additionalProperties": false } } -} +}
\ No newline at end of file diff --git a/app/validators/json_schemas/build_report_result_data.json b/app/validators/json_schemas/build_report_result_data.json index 0a12c9c39a7..d109389a046 100644 --- a/app/validators/json_schemas/build_report_result_data.json +++ b/app/validators/json_schemas/build_report_result_data.json @@ -3,11 +3,16 @@ "description": "Build report result data", "type": "object", "properties": { - "coverage": { "type": "float" }, + "coverage": { + "type": "number", + "format": "float" + }, "tests": { "type": "object", - "items": { "$ref": "./build_report_result_data_tests.json" } + "items": { + "$ref": "./build_report_result_data_tests.json" + } } }, "additionalProperties": false -} +}
\ No newline at end of file diff --git a/app/validators/json_schemas/build_report_result_data_tests.json b/app/validators/json_schemas/build_report_result_data_tests.json index 610070fde5f..3b6a2688313 100644 --- a/app/validators/json_schemas/build_report_result_data_tests.json +++ b/app/validators/json_schemas/build_report_result_data_tests.json @@ -3,12 +3,24 @@ "description": "Build report result data tests", "type": "object", "properties": { - "name": { "type": "string" }, - "duration": { "type": "string" }, - "failed": { "type": "integer" }, - "errored": { "type": "integer" }, - "skipped": { "type": "integer" }, - "success": { "type": "integer" } + "name": { + "type": "string" + }, + "duration": { + "type": "string" + }, + "failed": { + "type": "integer" + }, + "errored": { + "type": "integer" + }, + "skipped": { + "type": "integer" + }, + "success": { + "type": "integer" + } }, "additionalProperties": false -} +}
\ No newline at end of file diff --git a/app/validators/json_schemas/ci_secure_file_metadata.json b/app/validators/json_schemas/ci_secure_file_metadata.json index 46a7ff60b8f..66e778d6026 100644 --- a/app/validators/json_schemas/ci_secure_file_metadata.json +++ b/app/validators/json_schemas/ci_secure_file_metadata.json @@ -4,10 +4,10 @@ "properties": { "id": { "type": "string" }, "team_name": { "type": "string" }, - "team_id": { "type": "string" }, + "team_id": { "type": "array" }, "app_name": { "type": "string" }, "app_id": { "type": "string" }, - "app_id_prefix": { "type": "string" }, + "app_id_prefix": { "type": "array" }, "xcode_managed": { "type": "boolean" }, "entitlements": { "type": "object" }, "devices": { "type": "array" }, diff --git a/app/validators/json_schemas/daily_build_group_report_result_data.json b/app/validators/json_schemas/daily_build_group_report_result_data.json index 2b073506375..5b153b47b1e 100644 --- a/app/validators/json_schemas/daily_build_group_report_result_data.json +++ b/app/validators/json_schemas/daily_build_group_report_result_data.json @@ -3,7 +3,10 @@ "description": "Daily build group report result data", "type": "object", "properties": { - "coverage": { "type": "float" } + "coverage": { + "type": "number", + "format": "float" + } }, "additionalProperties": false -} +}
\ No newline at end of file diff --git a/app/validators/json_schemas/merge_request_predictions_suggested_reviewers.json b/app/validators/json_schemas/merge_request_predictions_suggested_reviewers.json deleted file mode 100644 index 8e80b52d9b8..00000000000 --- a/app/validators/json_schemas/merge_request_predictions_suggested_reviewers.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "description": "Merge request predictions suggested reviewers", - "type": "object", - "properties": { - "top_n": { "type": "number" }, - "version": { "type": "string" }, - "reviewers": { "type": "array" } - }, - "additionalProperties": true -} diff --git a/app/validators/json_schemas/web_hooks_url_variables.json b/app/validators/json_schemas/web_hooks_url_variables.json index ea504d114e3..27b251a059f 100644 --- a/app/validators/json_schemas/web_hooks_url_variables.json +++ b/app/validators/json_schemas/web_hooks_url_variables.json @@ -8,7 +8,7 @@ "^[A-Za-z]+[0-9]*(?:[._-][A-Za-z0-9]+)*$": { "type": "string", "minLength": 1, - "maxLength": 100 + "maxLength": 2048 } } } diff --git a/app/views/abuse_reports/new.html.haml b/app/views/abuse_reports/new.html.haml index aaa85e81bd4..d5dfddef837 100644 --- a/app/views/abuse_reports/new.html.haml +++ b/app/views/abuse_reports/new.html.haml @@ -1,8 +1,8 @@ -- page_title _("Report abuse to admin") +- page_title _("Report abuse to administrator") %h1.page-title.gl-font-size-h-display - = _("Report abuse to admin") + = _("Report abuse to administrator") %p - = _("Please use this form to report to the admin users who create spam issues, comments or behave inappropriately.") + = _("Use this form to report to the administrator users who create spam issues, comments or behave inappropriately.") %p = _("A member of the abuse team will review your report as soon as possible.") %hr diff --git a/app/views/admin/abuse_reports/_abuse_report.html.haml b/app/views/admin/abuse_reports/_abuse_report.html.haml index 00e5650b551..eeedd58ec15 100644 --- a/app/views/admin/abuse_reports/_abuse_report.html.haml +++ b/app/views/admin/abuse_reports/_abuse_report.html.haml @@ -24,11 +24,13 @@ = markdown_field(abuse_report, :message) %td - if user - = link_to _('Remove user & report'), admin_abuse_report_path(abuse_report, remove_user: true), - data: { confirm: _("USER %{user} WILL BE REMOVED! Are you sure?") % { user: user.name }, confirm_btn_variant: "danger" }, aria: { label: _('Remove user & report') }, remote: true, method: :delete, class: "gl-button btn btn-block btn-danger js-remove-tr" + = render Pajamas::ButtonComponent.new(href: admin_abuse_report_path(abuse_report, remove_user: true), variant: :danger, block: true, button_options: { data: { confirm: _("USER %{user} WILL BE REMOVED! Are you sure?") % { user: user.name }, confirm_btn_variant: "danger", remote: true, method: :delete }, class: "js-remove-tr gl-mb-5" }) do + = _('Remove user & report') - if user && !user.blocked? - = link_to _('Block user'), block_admin_user_path(user), data: { confirm: _('USER WILL BE BLOCKED! Are you sure?') }, aria: { label: _('Block user') }, method: :put, class: "gl-button btn btn-default btn-block" + = render Pajamas::ButtonComponent.new(href: block_admin_user_path(user), block: true, button_options: { data: { confirm: _('USER WILL BE BLOCKED! Are you sure?'), method: :put }, class: "gl-mb-5" }) do + = _('Block user') - else - .gl-button.btn.btn-default.disabled.btn-block + = render Pajamas::ButtonComponent.new(href: block_admin_user_path(user), block: true, disabled: true, button_options: { data: { confirm: _('USER WILL BE BLOCKED! Are you sure?'), method: :put }, class: "gl-mb-5" }) do = _('Already blocked') - = link_to _('Remove report'), [:admin, abuse_report], remote: true, method: :delete, class: "gl-button btn btn-default btn-block btn-close js-remove-tr" + = render Pajamas::ButtonComponent.new(href: [:admin, abuse_report], block: true, button_options: { data: { remote: true, method: :delete }, class: "js-remove-tr" }) do + = _('Remove report') 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 0f7b10f822d..21f69f6700f 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -46,7 +46,7 @@ = f.text_field :user_default_internal_regex, placeholder: _('Regex pattern'), class: 'form-control gl-form-input gl-mt-2' .help-block = _('Specify an email address regex pattern to identify default internal users.') - = link_to _('Learn more.'), help_page_path('user/permissions', anchor: 'setting-new-users-to-external'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/admin_area/external_users', anchor: 'set-a-new-user-to-external'), target: '_blank', rel: 'noopener noreferrer' - unless Gitlab.com? .form-group = f.label :deactivate_dormant_users, _('Dormant users'), class: 'label-bold' diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index f6635ad17ef..8fafa52cd4c 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -1,6 +1,6 @@ .settings-content = gitlab_ui_form_for @application_setting, url: ci_cd_admin_application_settings_path(anchor: 'js-ci-cd-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting ) + = form_errors(@application_setting) %fieldset .form-group @@ -61,7 +61,7 @@ %h4 = s_('AdminSettings|CI/CD limits') %p - = s_('AdminSettings|Set limit to 0 to disable it.') + = s_('AdminSettings|By default, set a limit to 0 to have no limit.') .scrolling-tabs-container.inner-page-scroll-tabs - if @plans.size > 1 %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs.gl-mb-5 @@ -94,10 +94,14 @@ .form-group = f.label :ci_needs_size_limit, s_('AdminSettings|Maximum number of DAG dependencies that a job can have') = f.number_field :ci_needs_size_limit, class: 'form-control gl-form-input' + .form-text.text-muted= s_('AdminSettings|This limit cannot be disabled. Set to 0 to block all DAG dependencies.') .form-group = f.label :ci_registered_group_runners, s_('AdminSettings|Maximum number of runners registered per group') = f.number_field :ci_registered_group_runners, class: 'form-control gl-form-input' .form-group = f.label :ci_registered_project_runners, s_('AdminSettings|Maximum number of runners registered per project') = f.number_field :ci_registered_project_runners, class: 'form-control gl-form-input' + .form-group + = f.label :pipeline_hierarchy_size, s_("AdminSettings|Maximum number of downstream pipelines in a pipeline's hierarchy tree") + = f.number_field :pipeline_hierarchy_size, class: 'form-control gl-form-input' = f.submit s_('AdminSettings|Save %{name} limits').html_safe % { name: plan.name.capitalize }, pajamas_button: true diff --git a/app/views/admin/application_settings/_default_branch.html.haml b/app/views/admin/application_settings/_default_branch.html.haml index 7be4bac02fd..67de5ffb2b9 100644 --- a/app/views/admin/application_settings/_default_branch.html.haml +++ b/app/views/admin/application_settings/_default_branch.html.haml @@ -8,7 +8,7 @@ = f.label :default_branch_name, _('Initial default branch name'), class: 'label-light' = f.text_field :default_branch_name, placeholder: Gitlab::DefaultBranch.value, class: 'form-control gl-form-input' %span.form-text.text-muted - = (s_("AdminSettings|If not specified at the group or instance level, the default is %{default_initial_branch_name}. Does not affect existing repositories.") % { default_initial_branch_name: fallback_branch_name } ).html_safe + = (s_("AdminSettings|If not specified at the group or instance level, the default is %{default_initial_branch_name}. Does not affect existing repositories.") % { default_initial_branch_name: fallback_branch_name }).html_safe = render 'shared/default_branch_protection', f: f diff --git a/app/views/admin/application_settings/_error_tracking.html.haml b/app/views/admin/application_settings/_error_tracking.html.haml index 5a8aba5784e..aa42cd99e89 100644 --- a/app/views/admin/application_settings/_error_tracking.html.haml +++ b/app/views/admin/application_settings/_error_tracking.html.haml @@ -37,4 +37,4 @@ = f.label :error_tracking_api_url, _('Opstrace endpoint for Error Tracking integration'), class: 'label-light' = f.text_field :error_tracking_api_url, class: 'form-control gl-form-input' - = f.submit _('Save changes'), class: 'gl-button btn btn-confirm' + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_git_lfs_limits.html.haml b/app/views/admin/application_settings/_git_lfs_limits.html.haml index b8970a5bcf1..638984ae97a 100644 --- a/app/views/admin/application_settings/_git_lfs_limits.html.haml +++ b/app/views/admin/application_settings/_git_lfs_limits.html.haml @@ -15,4 +15,4 @@ = f.label :throttle_authenticated_git_lfs_period_in_seconds, _('Authenticated Git LFS rate limit period in seconds'), class: 'gl-font-weight-bold' = f.number_field :throttle_authenticated_git_lfs_period_in_seconds, class: 'form-control gl-form-input' - = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_grafana.html.haml b/app/views/admin/application_settings/_grafana.html.haml index 7f305b9ad9c..e2a53106cec 100644 --- a/app/views/admin/application_settings/_grafana.html.haml +++ b/app/views/admin/application_settings/_grafana.html.haml @@ -11,4 +11,4 @@ = f.text_field :grafana_url, class: 'form-control gl-form-input', placeholder: '/-/grafana' %span.form-text.text-muted#support_help_block= _('URL of the Grafana instance to link to from the Metrics Dashboard menu item.') - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_kroki.html.haml b/app/views/admin/application_settings/_kroki.html.haml index 4f5a313d7b7..f1f6dd34401 100644 --- a/app/views/admin/application_settings/_kroki.html.haml +++ b/app/views/admin/application_settings/_kroki.html.haml @@ -24,7 +24,7 @@ - install_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: install_link_url } = html_escape(_('Use the public cloud instance URL (%{kroki_public_url}) or %{install_link_start}install Kroki%{install_link_end} on your own infrastructure and use your own instance URL.')) % { kroki_public_url: '<code>https://kroki.io</code>'.html_safe, install_link_start: install_link_start, install_link_end: '</a>'.html_safe } .form-group - = f.label :kroki_formats, 'Additional diagram formats', class: 'label-bold' + = f.label :kroki_formats, _('Additional diagram formats'), class: 'label-bold' .form-text.text-muted - container_link_url = 'https://docs.kroki.io/kroki/setup/install/#images' - container_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: container_link_url } diff --git a/app/views/admin/application_settings/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml index 90cb34395d8..9ec4afec484 100644 --- a/app/views/admin/application_settings/_localization.html.haml +++ b/app/views/admin/application_settings/_localization.html.haml @@ -15,5 +15,12 @@ - time_tracking_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: time_tracking_help_link } = f.gitlab_ui_checkbox_component :time_tracking_limit_to_hours, _('Limit display of time tracking units to hours.'), help_text: _('Display time tracking in issues in total hours only. %{link_start}What is time tracking?%{link_end}').html_safe % { link_start: time_tracking_help_link_start, link_end: '</a>'.html_safe } + .form-group + = f.label :default_preferred_language, class: 'label-bold' do + = _('Default language') + = f.select :default_preferred_language, default_preferred_language_choices, {}, class: 'gl-form-select custom-select' + .form-text.text-muted + = s_('Default language for users who are not logged in.') + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_mailgun.html.haml b/app/views/admin/application_settings/_mailgun.html.haml index 1604419869c..fb15f6e79a5 100644 --- a/app/views/admin/application_settings/_mailgun.html.haml +++ b/app/views/admin/application_settings/_mailgun.html.haml @@ -19,4 +19,4 @@ = f.label :mailgun_signing_key, _('Mailgun HTTP webhook signing key'), class: 'label-light' = f.text_field :mailgun_signing_key, class: 'form-control gl-form-input' - = f.submit _('Save changes'), class: 'gl-button btn btn-confirm' + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml index 3505a3bf3ee..1821c8ef4bb 100644 --- a/app/views/admin/application_settings/_outbound.html.haml +++ b/app/views/admin/application_settings/_outbound.html.haml @@ -20,6 +20,6 @@ .form-group = f.gitlab_ui_checkbox_component :dns_rebinding_protection_enabled, s_('OutboundRequests|Enforce DNS rebinding attack protection'), - help_text: _('OutboundRequests|Resolve IP addresses once and uses them to submit requests.') + help_text: s_('OutboundRequests|Resolve IP addresses once and uses them to submit requests.') = f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_performance_bar.html.haml b/app/views/admin/application_settings/_performance_bar.html.haml index d4f6d84ea74..c09ba01b7ed 100644 --- a/app/views/admin/application_settings/_performance_bar.html.haml +++ b/app/views/admin/application_settings/_performance_bar.html.haml @@ -4,7 +4,7 @@ %fieldset .form-group = f.gitlab_ui_checkbox_component :performance_bar_enabled, - s_("Allow non-administrators access to the performance bar"), + _("Allow non-administrators access to the performance bar"), checkbox_options: { data: { qa_selector: 'enable_performance_bar_checkbox' } } .form-group = f.label :performance_bar_allowed_group_path, _('Allow access to members of the following group'), class: 'label-bold' diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml index 5c86ce8dbfb..42f289d87b2 100644 --- a/app/views/admin/application_settings/_plantuml.html.haml +++ b/app/views/admin/application_settings/_plantuml.html.haml @@ -22,4 +22,4 @@ .form-text.text-muted = _('The hostname of your PlantUML server.') - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml index aaf76c5ff7a..332d3a94b92 100644 --- a/app/views/admin/application_settings/_repository_check.html.haml +++ b/app/views/admin/application_settings/_repository_check.html.haml @@ -19,28 +19,35 @@ %h4= _("Housekeeping") .form-group - help_text = _("Run housekeeping tasks to automatically optimize Git repositories. Disabling this option will cause performance to degenerate over time.") - - help_link = link_to s_('Learn more.'), help_page_path('administration/housekeeping.md', anchor: 'configure-push-based-maintenance'), target: '_blank', rel: 'noopener noreferrer' + - help_link = link_to _('Learn more.'), help_page_path('administration/housekeeping.md', anchor: 'configure-push-based-maintenance'), target: '_blank', rel: 'noopener noreferrer' = f.gitlab_ui_checkbox_component :housekeeping_enabled, _("Enable automatic repository housekeeping"), help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link } - .form-group - = f.label :housekeeping_incremental_repack_period, 'Incremental repack period', class: 'label-bold' - = f.number_field :housekeeping_incremental_repack_period, class: 'form-control gl-form-input' - .form-text.text-muted - = html_escape(s_('Number of Git pushes after which an incremental %{code_start}git repack%{code_end} is run.')) % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe } - .form-group - = f.label :housekeeping_full_repack_period, 'Full repack period', class: 'label-bold' - = f.number_field :housekeeping_full_repack_period, class: 'form-control gl-form-input' - .form-text.text-muted - = html_escape(s_('Number of Git pushes after which a full %{code_start}git repack%{code_end} is run.')) % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe } - .form-group - = f.label :housekeeping_gc_period, _('Git GC period'), class: 'label-bold' - = f.number_field :housekeeping_gc_period, class: 'form-control gl-form-input' - .form-text.text-muted - = html_escape(s_('Number of Git pushes after which %{code_start}git gc%{code_end} is run.')) % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe } + - if Feature.enabled?(:optimized_housekeeping) + .form-group + = f.label :housekeeping_incremental_repack_period, _('Optimize repository period'), class: 'label-bold' + = f.number_field :housekeeping_incremental_repack_period, class: 'form-control gl-form-input' + .form-text.text-muted + = _('Number of Git pushes after which Gitaly is asked to optimize a repository.') + - else + .form-group + = f.label :housekeeping_incremental_repack_period, 'Incremental repack period', class: 'label-bold' + = f.number_field :housekeeping_incremental_repack_period, class: 'form-control gl-form-input' + .form-text.text-muted + = html_escape(s_('Number of Git pushes after which an incremental %{code_start}git repack%{code_end} is run.')) % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe } + .form-group + = f.label :housekeeping_full_repack_period, 'Full repack period', class: 'label-bold' + = f.number_field :housekeeping_full_repack_period, class: 'form-control gl-form-input' + .form-text.text-muted + = html_escape(s_('Number of Git pushes after which a full %{code_start}git repack%{code_end} is run.')) % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe } + .form-group + = f.label :housekeeping_gc_period, _('Git GC period'), class: 'label-bold' + = f.number_field :housekeeping_gc_period, class: 'form-control gl-form-input' + .form-text.text-muted + = html_escape(s_('Number of Git pushes after which %{code_start}git gc%{code_end} is run.')) % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe } .sub-section %h4= s_("AdminSettings|Inactive project deletion") .js-inactive-project-deletion-form{ data: inactive_projects_deletion_data(@application_setting) } - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_repository_static_objects.html.haml b/app/views/admin/application_settings/_repository_static_objects.html.haml index d962d050ebc..b301ec15a0e 100644 --- a/app/views/admin/application_settings/_repository_static_objects.html.haml +++ b/app/views/admin/application_settings/_repository_static_objects.html.haml @@ -1,4 +1,4 @@ -= form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-repository-static-objects-settings'), html: { class: 'fieldset-form' } do |f| += gitlab_ui_form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-repository-static-objects-settings'), html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) %fieldset @@ -15,4 +15,4 @@ %span.form-text.text-muted#static_objects_external_storage_auth_token_help_block = _('Secure token that identifies an external storage request.') - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml index 12dd8816783..066d77c792b 100644 --- a/app/views/admin/application_settings/_repository_storage.html.haml +++ b/app/views/admin/application_settings/_repository_storage.html.haml @@ -20,7 +20,7 @@ - weights_link_url = help_page_path('administration/repository_storage_paths.md', anchor: 'configure-where-new-repositories-are-stored') - weights_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: weights_link_url } = html_escape(s_('Enter %{weights_link_start}weights%{weights_link_end} for storages for new repositories. Configured storages appear below.')) % { weights_link_start: weights_link_start, weights_link_end: '</a>'.html_safe } - = link_to s_('Learn more.'), help_page_path('administration/repository_storage_paths.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/repository_storage_paths.md'), target: '_blank', rel: 'noopener noreferrer' .form-check = f.fields_for :repository_storages_weighted, storage_weights do |storage_form| - Gitlab.config.repositories.storages.each_key do |storage| diff --git a/app/views/admin/application_settings/_runner_registrars_form.html.haml b/app/views/admin/application_settings/_runner_registrars_form.html.haml index 1d6051a06ea..08486a808bf 100644 --- a/app/views/admin/application_settings/_runner_registrars_form.html.haml +++ b/app/views/admin/application_settings/_runner_registrars_form.html.haml @@ -5,7 +5,7 @@ .gl-form-group %span.form-text.gl-mb-3.gl-mt-0 = _('If no options are selected, only administrators can register runners.') - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'prevent-users-from-registering-runners'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'restrict-runner-registration-by-all-users-in-an-instance'), target: '_blank', rel: 'noopener noreferrer' = hidden_field_tag "application_setting[valid_runner_registrars][]", nil - ApplicationSetting::VALID_RUNNER_REGISTRAR_TYPES.each do |type| = f.gitlab_ui_checkbox_component :valid_runner_registrars, s_("Runners|Members of the %{type} can register runners") % { type: type }, @@ -13,4 +13,4 @@ checked_value: type, unchecked_value: nil - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_search_limits.html.haml b/app/views/admin/application_settings/_search_limits.html.haml index 945c9397f0d..396c263dd5d 100644 --- a/app/views/admin/application_settings/_search_limits.html.haml +++ b/app/views/admin/application_settings/_search_limits.html.haml @@ -1,4 +1,4 @@ -= form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-search-limits-settings'), html: { class: 'fieldset-form' } do |f| += gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-search-limits-settings'), html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) %fieldset @@ -13,4 +13,4 @@ = f.number_field :search_rate_limit_unauthenticated, class: 'form-control gl-form-input' - = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true diff --git a/app/views/admin/application_settings/_spam.html.haml b/app/views/admin/application_settings/_spam.html.haml index bb512940be2..96face44344 100644 --- a/app/views/admin/application_settings/_spam.html.haml +++ b/app/views/admin/application_settings/_spam.html.haml @@ -50,8 +50,7 @@ = f.label :akismet_api_key, _('Akismet API Key'), class: 'label-bold' = f.text_field :akismet_api_key, class: 'form-control gl-form-input' .form-text.text-muted - Generate API key at - %a{ href: 'http://www.akismet.com', target: 'blank', rel: 'noopener noreferrer' } http://www.akismet.com + = _("Generate API key at %{site}").html_safe % { site: link_to('http://www.akismet.com', 'http://www.akismet.com', target: 'blank', ref: 'noopener noreferrer') } %h5 = _('IP address restrictions') @@ -86,4 +85,4 @@ = f.text_field :spam_check_api_key, class: 'form-control gl-form-input' .form-text.text-muted= _('The API key used by GitLab for accessing the Spam Check service endpoint.') - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_terminal.html.haml b/app/views/admin/application_settings/_terminal.html.haml index c53f63e124b..b07db09d06c 100644 --- a/app/views/admin/application_settings/_terminal.html.haml +++ b/app/views/admin/application_settings/_terminal.html.haml @@ -1,4 +1,4 @@ -= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-terminal-settings'), html: { class: 'fieldset-form', id: 'terminal-settings' } do |f| += gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-terminal-settings'), html: { class: 'fieldset-form', id: 'terminal-settings' } do |f| = form_errors(@application_setting) %fieldset @@ -7,4 +7,4 @@ = f.number_field :terminal_max_session_time, class: 'form-control gl-form-input' .form-text.text-muted = _('Maximum time, in seconds, for a web terminal websocket connection. 0 for unlimited.') - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_terraform_limits.html.haml b/app/views/admin/application_settings/_terraform_limits.html.haml new file mode 100644 index 00000000000..bdb0ba5cc85 --- /dev/null +++ b/app/views/admin/application_settings/_terraform_limits.html.haml @@ -0,0 +1,11 @@ += gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-terraform-limits-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :max_terraform_state_size_bytes, s_('TerraformLimits|Terraform state size limit (bytes)'), class: 'label-bold' + = f.number_field :max_terraform_state_size_bytes, class: 'form-control gl-form-input' + .form-text.text-muted + = s_("TerraformLimits|Maximum file size (in bytes) of Terraform state files. Set to 0 for no limit.") + + = f.submit _('Save changes'), pajamas_button: true 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 85bee72e863..9c8770b8998 100644 --- a/app/views/admin/application_settings/_visibility_and_access.html.haml +++ b/app/views/admin/application_settings/_visibility_and_access.html.haml @@ -36,10 +36,14 @@ = render_if_exists 'admin/application_settings/ldap_access_setting', form: f - .form-group + .form-group{ data: { testid: 'project-export' } } = f.label :project_export, s_('AdminSettings|Project export'), class: 'label-bold' = f.gitlab_ui_checkbox_component :project_export_enabled, s_('AdminSettings|Enabled') + .form-group{ data: { testid: 'bulk-import' } } + = f.label :bulk_import, s_('AdminSettings|Enable migrating GitLab groups and projects by direct transfer'), class: 'gl-font-weight-bold' + = f.gitlab_ui_checkbox_component :bulk_import_enabled, s_('AdminSettings|Enabled') + .form-group %label.label-bold= _('Enabled Git access protocols') = select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control') @@ -67,4 +71,4 @@ -# This is added for Jihu edition in https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/1112 = render_if_exists 'admin/application_settings/disable_download_button', f: f - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml b/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml index 415606c055d..d7bb3a85f3a 100644 --- a/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml +++ b/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml @@ -20,7 +20,7 @@ label_options: { class: 'gl-font-weight-bold!' } .form-group.js-toggle-colors-container - %button.btn.gl-button.btn-link.js-toggle-colors-link{ type: 'button' } + = render Pajamas::ButtonComponent.new(variant: :link, button_options: { class: 'js-toggle-colors-link' }) do = _('Customize colors') .form-group.js-toggle-colors-container.hide = form.label :message_background_color, _('Background Color'), class: 'col-form-label label-bold' diff --git a/app/views/admin/application_settings/appearances/show.html.haml b/app/views/admin/application_settings/appearances/show.html.haml index 77a08913666..1e55190d53b 100644 --- a/app/views/admin/application_settings/appearances/show.html.haml +++ b/app/views/admin/application_settings/appearances/show.html.haml @@ -1,4 +1,5 @@ - page_title _("Appearance") - @content_class = "limit-container-width" unless fluid_layout +- add_page_specific_style 'page_bundles/settings' = render 'form' diff --git a/app/views/admin/application_settings/ci/_header.html.haml b/app/views/admin/application_settings/ci/_header.html.haml index 0adb6cbbcf0..79c07f491fc 100644 --- a/app/views/admin/application_settings/ci/_header.html.haml +++ b/app/views/admin/application_settings/ci/_header.html.haml @@ -8,12 +8,13 @@ %p = _('Variables store information, like passwords and secret keys, that you can use in job scripts. All projects on the instance can use these variables.') - = link_to s_('Learn more.'), help_page_path('ci/variables/index', anchor: 'add-a-cicd-variable-to-an-instance'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'add-a-cicd-variable-to-an-instance'), target: '_blank', rel: 'noopener noreferrer' %p = _('Variables can be:') %ul %li = html_escape(_('%{code_open}Protected:%{code_close} Only exposed to protected branches or protected tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'protected-cicd-variables'), target: '_blank', rel: 'noopener noreferrer' %li = html_escape(_('%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml index b7244c45871..0414382a108 100644 --- a/app/views/admin/application_settings/ci_cd.html.haml +++ b/app/views/admin/application_settings/ci_cd.html.haml @@ -1,5 +1,6 @@ - breadcrumb_title _("CI/CD") - page_title _("CI/CD") +- add_page_specific_style 'page_bundles/settings' - @content_class = "limit-container-width" unless fluid_layout %section.settings.no-animate#js-ci-cd-variables{ class: ('expanded' if expanded_by_default?) } diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index 6d8428d1aa6..8c9d54cd5d8 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -1,5 +1,6 @@ - breadcrumb_title _("General") - page_title _("General") +- add_page_specific_style 'page_bundles/settings' - @content_class = "limit-container-width" unless fluid_layout %section.settings.as-visibility-access.no-animate#js-visibility-settings{ class: ('expanded' if expanded_by_default?) } @@ -124,4 +125,4 @@ = render 'admin/application_settings/eks' = render 'admin/application_settings/floc' = render_if_exists 'admin/application_settings/add_license' -= render 'admin/application_settings/jira_connect' if Feature.enabled?(:jira_connect_oauth_self_managed_setting, current_user) += render 'admin/application_settings/jira_connect' diff --git a/app/views/admin/application_settings/integrations.html.haml b/app/views/admin/application_settings/integrations.html.haml index d818c587b79..fd1ad5cd304 100644 --- a/app/views/admin/application_settings/integrations.html.haml +++ b/app/views/admin/application_settings/integrations.html.haml @@ -1,5 +1,6 @@ - breadcrumb_title s_('Integrations|Instance-level integration management') - page_title s_('Integrations|Instance-level integration management') +- add_page_specific_style 'page_bundles/settings' - @content_class = 'limit-container-width' unless fluid_layout %h3= s_('Integrations|Instance-level integration management') 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 b79b189e9cf..b5981578866 100644 --- a/app/views/admin/application_settings/metrics_and_profiling.html.haml +++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml @@ -2,6 +2,7 @@ - breadcrumb_title _("Metrics and profiling") - page_title _("Metrics and profiling") +- add_page_specific_style 'page_bundles/settings' - @content_class = "limit-container-width" unless fluid_layout %section.settings.as-prometheus.no-animate#js-prometheus-settings{ class: ('expanded' if expanded_by_default?) } @@ -23,7 +24,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Link to your Grafana instance.') - = link_to s_('Learn more.'), help_page_path('administration/monitoring/performance/grafana_configuration.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/monitoring/performance/grafana_configuration.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'grafana' @@ -36,7 +37,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Enable access to the performance bar for non-administrators in a given group.') - = link_to s_('Learn more.'), help_page_path('administration/monitoring/performance/performance_bar.md', anchor: 'enable-the-performance-bar-for-non-administrators'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/monitoring/performance/performance_bar.md', anchor: 'enable-the-performance-bar-for-non-administrators'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'performance_bar' diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml index 485b3a9828b..779263b439f 100644 --- a/app/views/admin/application_settings/network.html.haml +++ b/app/views/admin/application_settings/network.html.haml @@ -1,5 +1,6 @@ - breadcrumb_title _("Network") - page_title _("Network") +- add_page_specific_style 'page_bundles/settings' - @content_class = "limit-container-width" unless fluid_layout %section.settings.as-performance.no-animate#js-performance-settings{ class: ('expanded' if expanded_by_default?) } diff --git a/app/views/admin/application_settings/preferences.html.haml b/app/views/admin/application_settings/preferences.html.haml index bd92f7d490c..dd6666542ca 100644 --- a/app/views/admin/application_settings/preferences.html.haml +++ b/app/views/admin/application_settings/preferences.html.haml @@ -1,5 +1,6 @@ - breadcrumb_title _("Preferences") - page_title _("Preferences") +- add_page_specific_style 'page_bundles/settings' - @content_class = "limit-container-width" unless fluid_layout %section.settings.as-email.no-animate#js-email-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'email_content' } } @@ -32,7 +33,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Additional text for the sign-in and Help page.') - = link_to s_('Learn more.'), help_page_path('user/admin_area/settings/help_page.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/admin_area/settings/help_page.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'help_page' @@ -79,7 +80,7 @@ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') %p - = _('Configure the default first day of the week and time tracking units.') + = _('Configure the default first day of the week, time tracking units, and default language.') .settings-content = render 'localization' @@ -96,3 +97,15 @@ .settings-content = render 'sidekiq_job_limits' + +%section.settings.as-terraform-limits.no-animate#js-terraform-limits-settings{ class: ('expanded' if expanded_by_default?) } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only + = s_('TerraformLimits|Terraform limits') + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do + = expanded_by_default? ? _('Collapse') : _('Expand') + %p + = s_('TerraformLimits|Limits for Terraform features') + = link_to s_('TerraformLimits|Learn more about Terraform limits.'), help_page_path('user/admin_area/settings/terraform_limits.md'), target: '_blank', rel: 'noopener noreferrer' + .settings-content + = render 'terraform_limits' diff --git a/app/views/admin/application_settings/reporting.html.haml b/app/views/admin/application_settings/reporting.html.haml index af9145bf1e7..3d803e95cd0 100644 --- a/app/views/admin/application_settings/reporting.html.haml +++ b/app/views/admin/application_settings/reporting.html.haml @@ -1,5 +1,6 @@ - breadcrumb_title _("Reporting") - page_title _("Reporting") +- add_page_specific_style 'page_bundles/settings' - @content_class = "limit-container-width" unless fluid_layout %section.settings.as-spam.no-animate#js-spam-settings{ class: ('expanded' if expanded_by_default?) } diff --git a/app/views/admin/application_settings/repository.html.haml b/app/views/admin/application_settings/repository.html.haml index 12063ea700b..50798ad476c 100644 --- a/app/views/admin/application_settings/repository.html.haml +++ b/app/views/admin/application_settings/repository.html.haml @@ -1,5 +1,6 @@ - breadcrumb_title _("Repository") - page_title _("Repository") +- add_page_specific_style 'page_bundles/settings' - @content_class = "limit-container-width" unless fluid_layout %section.settings.as-default-branch-name.no-animate#js-default-branch-name{ class: ('expanded' if expanded_by_default?) } @@ -21,7 +22,7 @@ = expanded_by_default? ? 'Collapse' : 'Expand' %p = _('Configure repository mirroring.') - = link_to s_('Learn more.'), help_page_path('user/project/repository/mirror/index.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/project/repository/mirror/index.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render partial: 'repository_mirrors_form' @@ -33,7 +34,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Configure repository storage.') - = link_to s_('Learn more.'), help_page_path('administration/repository_storage_paths.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/repository_storage_paths.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'repository_storage' @@ -60,6 +61,6 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Serve repository static objects (for example, archives and blobs) from external storage.') - = link_to s_('Learn more.'), help_page_path('administration/static_objects_external_storage.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/static_objects_external_storage.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'repository_static_objects' diff --git a/app/views/admin/application_settings/service_usage_data.html.haml b/app/views/admin/application_settings/service_usage_data.html.haml index 82b627e1805..d6860cc08ac 100644 --- a/app/views/admin/application_settings/service_usage_data.html.haml +++ b/app/views/admin/application_settings/service_usage_data.html.haml @@ -2,6 +2,7 @@ - breadcrumb_title name - page_title name +- add_page_specific_style 'page_bundles/settings' - @content_class = "limit-container-width" unless fluid_layout - payload_class = 'js-service-ping-payload' @@ -9,10 +10,10 @@ %h3= name - if @service_ping_data_present - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-payload-preview-trigger gl-mr-2', data: { payload_selector: ".#{payload_class}" } } ) do + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-payload-preview-trigger gl-mr-2', data: { payload_selector: ".#{payload_class}" } }) do = gl_loading_icon(css_class: 'js-spinner gl-display-none', inline: true) %span.js-text.gl-display-inline= _('Preview payload') - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-payload-download-trigger gl-mr-2', data: { endpoint: usage_data_admin_application_settings_path(format: :json) } } ) do + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-payload-download-trigger gl-mr-2', data: { endpoint: usage_data_admin_application_settings_path(format: :json) } }) do = gl_loading_icon(css_class: 'js-spinner gl-display-none', inline: true) %span.js-text.gl-display-inline= _('Download payload') %pre.js-syntax-highlight.code.highlight.gl-mt-2.gl-display-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } } diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml index b603c7e5f49..a92bad5e601 100644 --- a/app/views/admin/applications/index.html.haml +++ b/app/views/admin/applications/index.html.haml @@ -14,11 +14,13 @@ .gl-max-w-full.gl-m-auto %h1.h4.gl-font-size-h-display= s_('AdminArea|No applications found') - = link_to _('New application'), new_admin_application_path, class: 'btn gl-button btn-confirm' + = render Pajamas::ButtonComponent.new(href: new_admin_application_path, variant: :confirm) do + = s_('New application') - else %hr - %p= link_to _('New application'), new_admin_application_path, class: 'gl-button btn btn-confirm' + = render Pajamas::ButtonComponent.new(href: new_admin_application_path, variant: :confirm) do + = s_('New application') .table-responsive %table.b-table.gl-table.gl-w-full{ role: 'table' } diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml index dfd3b87c674..4e05eb31010 100644 --- a/app/views/admin/broadcast_messages/_form.html.haml +++ b/app/views/admin/broadcast_messages/_form.html.haml @@ -17,14 +17,14 @@ = f.label :broadcast_type, _('Type') .col-sm-10 = f.select :broadcast_type, broadcast_type_options, {}, class: 'form-control js-broadcast-message-type' - .form-group.row.js-broadcast-message-background-color-form-group{ class: ('hidden' unless @broadcast_message.banner? ) } + .form-group.row.js-broadcast-message-background-color-form-group{ class: ('hidden' unless @broadcast_message.banner?) } .col-sm-2.col-form-label = f.label :theme, _("Theme") .col-sm-10 .input-group = f.select :theme, broadcast_theme_options, {}, class: 'form-control js-broadcast-message-theme' - .form-group.row.js-broadcast-message-dismissable-form-group{ class: ('hidden' unless @broadcast_message.banner? ) } + .form-group.row.js-broadcast-message-dismissable-form-group{ class: ('hidden' unless @broadcast_message.banner?) } .col-sm-2.col-form-label.pt-0 = f.label :starts_at, _("Dismissable") .col-sm-10 @@ -62,6 +62,6 @@ = f.datetime_select :ends_at, {}, class: 'form-control form-control-inline' .form-actions - if @broadcast_message.persisted? - = f.submit _("Update broadcast message"), class: "btn gl-button btn-confirm" + = f.submit _("Update broadcast message"), pajamas_button: true - else - = f.submit _("Add broadcast message"), class: "btn gl-button btn-confirm" + = f.submit _("Add broadcast message"), pajamas_button: true diff --git a/app/views/admin/broadcast_messages/edit.html.haml b/app/views/admin/broadcast_messages/edit.html.haml index 569aaa29cc4..28301833f7d 100644 --- a/app/views/admin/broadcast_messages/edit.html.haml +++ b/app/views/admin/broadcast_messages/edit.html.haml @@ -1,4 +1,19 @@ - breadcrumb_title _("Messages") - page_title _("Broadcast Messages") +- vue_app_enabled = Feature.enabled?(:vue_broadcast_messages, current_user) -= render 'form' +- if vue_app_enabled + #js-broadcast-message{ data: { + id: @broadcast_message.id, + message: @broadcast_message.message, + broadcast_type: @broadcast_message.broadcast_type, + theme: @broadcast_message.theme, + dismissable: @broadcast_message.dismissable.to_s, + target_access_levels: @broadcast_message.target_access_levels, + target_path: @broadcast_message.target_path, + starts_at: @broadcast_message.starts_at, + ends_at: @broadcast_message.ends_at, + target_access_level_options: target_access_level_options.to_json, + } } +- else + = render 'form' diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml index 7559365e49a..7a005f9c982 100644 --- a/app/views/admin/broadcast_messages/index.html.haml +++ b/app/views/admin/broadcast_messages/index.html.haml @@ -10,6 +10,7 @@ - if vue_app_enabled #js-broadcast-messages{ data: { page: params[:page] || 1, + target_access_level_options: target_access_level_options.to_json, messages_count: @broadcast_messages.total_count, messages: @broadcast_messages.map { |message| { id: message.id, diff --git a/app/views/admin/dashboard/_security_newsletter_callout.html.haml b/app/views/admin/dashboard/_security_newsletter_callout.html.haml index 76bfa347480..7495298936d 100644 --- a/app/views/admin/dashboard/_security_newsletter_callout.html.haml +++ b/app/views/admin/dashboard/_security_newsletter_callout.html.haml @@ -10,5 +10,5 @@ = c.body do = s_('AdminArea|Sign up for the GitLab Security Newsletter to get notified for security updates.') = c.actions do - = link_to 'https://about.gitlab.com/company/preference-center/', target: '_blank', rel: 'noreferrer noopener', class: 'deferred-link gl-alert-action btn-confirm btn-md gl-button' do + = render Pajamas::ButtonComponent.new(variant: :confirm, href: 'https://about.gitlab.com/company/preference-center/', target: '_blank', button_options: { class: 'deferred-link gl-alert-action', rel: 'noreferrer noopener' }) do = s_('AdminArea|Sign up for the GitLab newsletter') diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 886402139e9..27ae7d523b9 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -32,7 +32,8 @@ = sprite_icon('project', size: 16, css_class: 'gl-text-gray-700') %h3.gl-m-0.gl-ml-3= approximate_count_with_delimiters(@counts, Project) .gl-mt-3.text-uppercase= s_('AdminArea|Projects') - = link_to(s_('AdminArea|New project'), new_project_path, class: "btn gl-button btn-default") + = render Pajamas::ButtonComponent.new(href: new_project_path) do + = s_('AdminArea|New project') = c.footer do .d-flex.align-items-center = link_to(s_('AdminArea|View latest projects'), admin_projects_path(sort: 'created_desc')) @@ -55,7 +56,8 @@ .gl-mt-3.text-uppercase = s_('AdminArea|Users') = link_to(s_('AdminArea|Users statistics'), admin_dashboard_stats_path, class: "text-capitalize gl-ml-2") - = link_to(s_('AdminArea|New user'), new_admin_user_path, class: "btn gl-button btn-default") + = render Pajamas::ButtonComponent.new(href: new_admin_user_path) do + = s_('AdminArea|New user') = c.footer do .d-flex.align-items-center = link_to(s_('AdminArea|View latest users'), admin_users_path({ sort: 'created_desc' })) @@ -68,7 +70,8 @@ = sprite_icon('group', size: 16, css_class: 'gl-text-gray-700') %h3.gl-m-0.gl-ml-3= approximate_count_with_delimiters(@counts, Group) .gl-mt-3.text-uppercase= s_('AdminArea|Groups') - = link_to(s_('AdminArea|New group'), new_admin_group_path, class: "btn gl-button btn-default") + = render Pajamas::ButtonComponent.new(href: new_admin_group_path) do + = s_('AdminArea|New group') = c.footer do .d-flex.align-items-center = link_to(s_('AdminArea|View latest groups'), admin_groups_path(sort: 'created_desc')) @@ -122,7 +125,7 @@ = s_('AdminArea|Components') - if show_version_check? .float-right - .js-gitlab-version-check-badge{ data: { "size": "lg", "actionable": "true" } } + .js-gitlab-version-check-badge{ data: { "size": "lg", "actionable": "true", "version": gitlab_version_check.to_json } } = link_to(sprite_icon('question'), "https://gitlab.com/gitlab-org/gitlab/-/blob/master/CHANGELOG.md", class: 'gl-ml-2', target: '_blank', rel: 'noopener noreferrer') %p = link_to _('GitLab'), general_admin_application_settings_path diff --git a/app/views/admin/deploy_keys/edit.html.haml b/app/views/admin/deploy_keys/edit.html.haml index acdf503727d..b51ab3457d6 100644 --- a/app/views/admin/deploy_keys/edit.html.haml +++ b/app/views/admin/deploy_keys/edit.html.haml @@ -7,4 +7,5 @@ = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key } .form-actions = f.submit _('Save changes'), pajamas_button: true - = link_to _('Cancel'), admin_deploy_keys_path, class: 'btn gl-button btn-default btn-cancel' + = render Pajamas::ButtonComponent.new(href: admin_deploy_keys_path) do + = _('Cancel') diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml index 7adba0d023b..20ee8c9f310 100644 --- a/app/views/admin/groups/_form.html.haml +++ b/app/views/admin/groups/_form.html.haml @@ -36,9 +36,11 @@ = render 'shared/group_tips' .gl-mt-5 = f.submit _('Create group'), pajamas_button: true - = link_to _('Cancel'), admin_groups_path, class: "gl-button btn btn-default btn-cancel" + = render Pajamas::ButtonComponent.new(href: admin_groups_path) do + = _('Cancel') - else .gl-mt-5 = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true - = link_to _('Cancel'), admin_group_path(@group), class: "gl-button btn btn-cancel" + = render Pajamas::ButtonComponent.new(href: admin_group_path(@group)) do + = _('Cancel') diff --git a/app/views/admin/groups/_group.html.haml b/app/views/admin/groups/_group.html.haml index a1afb1ddbfa..f9ebda2bc21 100644 --- a/app/views/admin/groups/_group.html.haml +++ b/app/views/admin/groups/_group.html.haml @@ -31,5 +31,7 @@ = visibility_level_icon(group.visibility_level) .controls.gl-flex-shrink-0.gl-ml-5 - = link_to _('Edit'), admin_group_edit_path(group), id: "edit_#{dom_id(group)}", class: 'btn gl-button btn-default' - = link_to _('Delete'), [:admin, group], aria: { label: _('Remove') }, data: { confirm: _("Are you sure you want to remove %{group_name}?") % { group_name: group.name }, confirm_btn_variant: 'danger' }, method: :delete, class: 'gl-button btn btn-danger' + = render Pajamas::ButtonComponent.new(href: admin_group_edit_path(group), button_options: { id: "edit_#{dom_id(group)}" }) do + = _('Edit') + = render Pajamas::ButtonComponent.new(href: [:admin, group], variant: :danger, button_options: { data: { confirm: _("Are you sure you want to remove %{group_name}?") % { group_name: group.name }, confirm_btn_variant: 'danger', method: :delete } }) do + = _('Delete') diff --git a/app/views/admin/groups/index.html.haml b/app/views/admin/groups/index.html.haml index 2ea5890be2c..2a49b9c5ad8 100644 --- a/app/views/admin/groups/index.html.haml +++ b/app/views/admin/groups/index.html.haml @@ -1,4 +1,5 @@ - page_title _("Groups") +- add_page_specific_style 'page_bundles/search' .top-area .gl-mt-3.gl-mb-3 @@ -9,7 +10,7 @@ = search_field_tag :name, params[:name].presence, class: "form-control search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: 'Search by name', data: { qa_selector: 'group_search_field' } = sprite_icon('search', css_class: 'search-icon') = render "shared/groups/dropdown", options_hash: admin_groups_sort_options_hash - = link_to new_admin_group_path, class: "gl-button btn btn-confirm" do + = render Pajamas::ButtonComponent.new(variant: :confirm, href: new_admin_group_path) do = _('New group') %ul.content-list = render @groups diff --git a/app/views/admin/hook_logs/show.html.haml b/app/views/admin/hook_logs/show.html.haml index 6fcaf2ea152..0ccde159905 100644 --- a/app/views/admin/hook_logs/show.html.haml +++ b/app/views/admin/hook_logs/show.html.haml @@ -5,8 +5,11 @@ %hr - if @hook_log.oversize? - = button_tag _("Resend Request"), class: "btn gl-button btn-default float-right gl-ml-3 has-tooltip", disabled: true, title: _("Request data is too large") + - tooltip = _("Request data is too large") + = render Pajamas::ButtonComponent.new(disabled: true, button_options: { class: 'gl-float-right gl-ml-3 has-tooltip', title: tooltip }) do + = _("Resend Request") - else - = link_to _("Resend Request"), retry_admin_hook_hook_log_path(@hook, @hook_log), method: :post, class: "btn gl-button btn-default float-right gl-ml-3" + = render Pajamas::ButtonComponent.new(href: retry_admin_hook_hook_log_path(@hook, @hook_log), method: :post, button_options: { class: 'gl-float-right gl-ml-3' }) do + = _("Resend Request") = render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log } diff --git a/app/views/admin/identities/_form.html.haml b/app/views/admin/identities/_form.html.haml index ba7687db9c7..ad78c677da1 100644 --- a/app/views/admin/identities/_form.html.haml +++ b/app/views/admin/identities/_form.html.haml @@ -1,4 +1,4 @@ -= form_for [:admin, @user, @identity], html: { class: 'fieldset-form' } do |f| += gitlab_ui_form_for [:admin, @user, @identity], html: { class: 'fieldset-form' } do |f| = form_errors(@identity) .form-group.row @@ -14,5 +14,5 @@ = f.text_field :extern_uid, class: 'form-control', required: true .form-actions - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/identities/_identity.html.haml b/app/views/admin/identities/_identity.html.haml index 3121cd2ae59..a24cd000464 100644 --- a/app/views/admin/identities/_identity.html.haml +++ b/app/views/admin/identities/_identity.html.haml @@ -14,11 +14,11 @@ icon: 'pencil', button_options: { title: _('Edit'), 'aria-label' => _('Edit'), - class: button_classes } ) + class: button_classes }) = render Pajamas::ButtonComponent.new(category: :tertiary, href: url_for([:admin, @user, identity]), icon: 'remove', button_options: { title: _('Delete'), 'aria-label' => _('Delete identity'), class: button_classes, - data: { method: :delete, confirm: _("Are you sure you want to remove this identity?") } } ) + data: { method: :delete, confirm: _("Are you sure you want to remove this identity?") } }) diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml index 21b19236683..d6f2898a383 100644 --- a/app/views/admin/labels/index.html.haml +++ b/app/views/admin/labels/index.html.haml @@ -27,6 +27,5 @@ %p = s_('AdminLabels|They can be used to categorize issues and merge requests.') .gl-display-flex.gl-flex-wrap.gl-justify-content-center - = link_to new_admin_label_path, class: "btn gl-mb-3 btn-confirm btn-md gl-button gl-mx-2" do - %span.gl-button-text - = _('New label') + = render Pajamas::ButtonComponent.new(href: new_admin_label_path) do + = _('New label') diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml index c7c30673d74..cf1bd2a8022 100644 --- a/app/views/admin/projects/_projects.html.haml +++ b/app/views/admin/projects/_projects.html.haml @@ -25,8 +25,8 @@ .controls.gl-flex-shrink-0.gl-ml-5 = render Pajamas::ButtonComponent.new(href: edit_project_path(project), button_options: { id: dom_id(project, :edit) }) do - = s_('Edit') - = render Pajamas::ButtonComponent.new(variant: :danger, button_options: { class: 'delete-project-button', data: { delete_project_url: admin_project_path(project), project_name: project.name } } ) do + = _('Edit') + = render Pajamas::ButtonComponent.new(variant: :danger, button_options: { class: 'delete-project-button', data: { delete_project_url: admin_project_path(project), project_name: project.name } }) do = s_('AdminProjects|Delete') = paginate @projects, theme: 'gitlab' diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml index f23a688dd48..18cd3400c60 100644 --- a/app/views/admin/projects/index.html.haml +++ b/app/views/admin/projects/index.html.haml @@ -1,4 +1,5 @@ - page_title _('Projects') +- add_page_specific_style 'page_bundles/search' - params[:visibility_level] ||= [] .top-area @@ -20,10 +21,9 @@ - namespace = Namespace.find(params[:namespace_id]) - current_namespace = "#{namespace.kind}: #{namespace.full_path}" %button.dropdown-menu-toggle.btn.btn-default.btn-md.gl-button.js-namespace-select{ data: { show_any: 'true', field_name: 'namespace_id', placeholder: current_namespace, update_location: 'true' }, type: 'button' } - %span.gl-new-dropdown-button-text + %span.gl-dropdown-button-text = current_namespace - = render 'shared/projects/dropdown' = link_to new_project_path, class: 'gl-button btn btn-confirm' do = _('New Project') diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index a60c3996cf2..829e9f508e0 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -6,8 +6,7 @@ %h1.page-title.gl-font-size-h-display = _('Project: %{name}') % { name: @project.full_name } - = link_to edit_project_path(@project), class: "btn btn-default gl-button float-right" do - = sprite_icon('pencil', css_class: 'gl-icon gl-mr-2') + = render Pajamas::ButtonComponent.new(href: edit_project_path(@project), icon: 'pencil', button_options: { class: 'gl-float-right' }) do = _('Edit') %hr - if @project.last_repository_check_failed? @@ -143,7 +142,7 @@ .col-sm-9 - placeholder = _('Search for Namespace') %button.dropdown-menu-toggle.btn.btn-default.btn-md.gl-button.js-namespace-select{ data: { field_name: 'new_namespace_id', placeholder: placeholder }, type: 'button' } - %span.gl-new-dropdown-button-text + %span.gl-dropdown-button-text = placeholder .form-group.row diff --git a/app/views/admin/topics/_form.html.haml b/app/views/admin/topics/_form.html.haml index 9b9d97950cc..544310e312c 100644 --- a/app/views/admin/topics/_form.html.haml +++ b/app/views/admin/topics/_form.html.haml @@ -38,10 +38,13 @@ - if @topic.new_record? .form-actions - = f.submit _('Create topic'), class: "gl-button btn btn-confirm" - = link_to _('Cancel'), admin_topics_path, class: "gl-button btn btn-default btn-cancel" + = f.submit _('Create topic'), pajamas_button: true + = render Pajamas::ButtonComponent.new(href: admin_topics_path) do + = _('Cancel') - else .form-actions - = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' } - = link_to _('Cancel'), admin_topics_path, class: "gl-button btn btn-cancel" + = f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' } + = render Pajamas::ButtonComponent.new(href: admin_topics_path) do + = _('Cancel') + diff --git a/app/views/admin/topics/index.html.haml b/app/views/admin/topics/index.html.haml index 77823ed7058..2f39f27208e 100644 --- a/app/views/admin/topics/index.html.haml +++ b/app/views/admin/topics/index.html.haml @@ -9,7 +9,7 @@ = sprite_icon('search', css_class: 'search-icon') .gl-flex-grow-1 .js-merge-topics{ data: { path: merge_admin_topics_path } } - = link_to new_admin_topic_path, class: "gl-button btn btn-confirm gl-w-full gl-md-w-auto" do + = render Pajamas::ButtonComponent.new(href: new_admin_topic_path, variant: 'confirm') do = _('New topic') %ul.content-list = render partial: 'topic', collection: @topics diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml index 6809f147ef8..eb151b40a65 100644 --- a/app/views/admin/users/_form.html.haml +++ b/app/views/admin/users/_form.html.haml @@ -76,7 +76,9 @@ %div - if @user.new_record? = f.submit _('Create user'), pajamas_button: true - = link_to _('Cancel'), admin_users_path, class: "gl-button btn btn-default btn-cancel" + = render Pajamas::ButtonComponent.new(href: admin_users_path) do + = _('Cancel') - else = f.submit _('Save changes'), pajamas_button: true - = link_to _('Cancel'), admin_user_path(@user), class: "gl-button btn btn-default btn-cancel" + = render Pajamas::ButtonComponent.new(href: admin_user_path(@user)) do + = _('Cancel') diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml index 1fa7c9c8651..6b5ec62bc77 100644 --- a/app/views/admin/users/_head.html.haml +++ b/app/views/admin/users/_head.html.haml @@ -40,7 +40,8 @@ = render Pajamas::ButtonComponent.new(variant: :default, button_options: { class: 'js-confirm-modal-button', data: confirm_user_data(@user) }) do = _('Confirm user') .gl-p-2 - = link_to _('New identity'), new_admin_user_identity_path(@user), class: "btn btn-primary gl-button" + = render Pajamas::ButtonComponent.new(variant: :confirm, href: new_admin_user_identity_path(@user)) do + = _('New identity') = gl_tabs_nav do = gl_tab_link_to _("Account"), admin_user_path(@user) = gl_tab_link_to _("Groups and projects"), projects_admin_user_path(@user) diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml index 6d85ff50fbe..96e6a264d8e 100644 --- a/app/views/admin/users/_users.html.haml +++ b/app/views/admin/users/_users.html.haml @@ -1,3 +1,5 @@ +- add_page_specific_style 'page_bundles/search' + - if registration_features_can_be_prompted? = render Pajamas::AlertComponent.new(variant: :tip, alert_options: { class: 'gl-my-5' }, diff --git a/app/views/ci/runner/_how_to_setup_runner.html.haml b/app/views/ci/runner/_how_to_setup_runner.html.haml index 8f4cc41822b..cdf25a9348c 100644 --- a/app/views/ci/runner/_how_to_setup_runner.html.haml +++ b/app/views/ci/runner/_how_to_setup_runner.html.haml @@ -17,8 +17,7 @@ = clipboard_button(target: '#registration_token', title: _("Copy token")) .gl-mt-3.gl-mb-3 -= button_to _("Reset registration token"), reset_token_url, -method: :put, class: 'gl-button btn btn-default', -data: { confirm: _("Are you sure you want to reset the registration token?") } += render Pajamas::ButtonComponent.new(variant: :default, method: :put, href: reset_token_url, button_options: { id: 'Reset registration token', data: { confirm: _("Are you sure you want to reset the registration token?") } }) do + = _('Reset registration token') #js-install-runner diff --git a/app/views/ci/variables/_content.html.haml b/app/views/ci/variables/_content.html.haml index b597c2d442a..37043a207ff 100644 --- a/app/views/ci/variables/_content.html.haml +++ b/app/views/ci/variables/_content.html.haml @@ -1,10 +1,12 @@ -= _('Variables store information, like passwords and secret keys, that you can use in job scripts.') -= link_to s_('Learn more.'), help_page_path('ci/variables/index'), target: '_blank', rel: 'noopener noreferrer' += format(s_('CiVariables|Variables store information, like passwords and secret keys, that you can use in job scripts. Each %{entity} can define a maximum of %{limit} variables.'), entity: entity, limit: variable_limit).html_safe += link_to _('Learn more.'), help_page_path('ci/variables/index'), target: '_blank', rel: 'noopener noreferrer' %p - = _('Variables can be:') + = _('Variables can have several attributes.') + = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'add-a-cicd-variable-to-an-instance'), target: '_blank', rel: 'noopener noreferrer' %ul %li = html_escape(_('%{code_open}Protected:%{code_close} Only exposed to protected branches or protected tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } %li = html_escape(_('%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer' + %li + = html_escape(_('%{code_open}Expanded:%{code_close} Variables with %{code_open}$%{code_close} will be treated as the start of a reference to another variable.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } diff --git a/app/views/ci/variables/_header.html.haml b/app/views/ci/variables/_header.html.haml index d6a9ce72d03..dfcf8f39533 100644 --- a/app/views/ci/variables/_header.html.haml +++ b/app/views/ci/variables/_header.html.haml @@ -7,4 +7,4 @@ = expanded ? _('Collapse') : _('Expand') %p - = render "ci/variables/content" + = render "ci/variables/content", entity: @entity, variable_limit: @variable_limit diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml index 08865abbe86..fdbf5132d40 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -23,7 +23,7 @@ aws_tip_deploy_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'deploy-your-application-to-ecs'), aws_tip_commands_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'use-an-image-to-run-aws-commands'), aws_tip_learn_link: help_page_path('ci/cloud_deployment/index.md'), - contains_variable_reference_link: help_page_path('ci/variables/index', anchor: 'use-variables-in-other-variables'), + contains_variable_reference_link: help_page_path('ci/variables/index', anchor: 'expand-cicd-variables'), protected_environment_variables_link: help_page_path('ci/variables/index', anchor: 'protected-cicd-variables'), masked_environment_variables_link: help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'), environment_scope_link: help_page_path('ci/environments/index', anchor: 'scope-environments-with-specs') } } diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml index c5e518d8526..a710655aa20 100644 --- a/app/views/ci/variables/_variable_row.html.haml +++ b/app/views/ci/variables/_variable_row.html.haml @@ -33,5 +33,4 @@ %p.masking-validation-error.gl-field-error.hide = s_("CiVariables|Cannot use Masked Variable with current value") = link_to sprite_icon('question-o'), help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer' - %button.gl-button.btn.btn-default.btn-icon.js-row-remove-button.ci-variable-row-remove-button.table-section{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') } - = sprite_icon('close') + = render Pajamas::ButtonComponent.new(icon: 'close', button_options: { class: 'js-row-remove-button ci-variable-row-remove-button table-section', 'aria-label': s_('CiVariables|Remove variable row') }) diff --git a/app/views/clusters/clusters/_details.html.haml b/app/views/clusters/clusters/_details.html.haml index 3079e024369..34408a9adeb 100644 --- a/app/views/clusters/clusters/_details.html.haml +++ b/app/views/clusters/clusters/_details.html.haml @@ -4,8 +4,6 @@ %section.settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4= s_('ClusterIntegration|Provider details') - %button.btn.gl-button.js-settings-toggle{ type: 'button' } - = expanded ? _('Collapse') : _('Expand') %p= s_('ClusterIntegration|See and edit the details for your Kubernetes cluster') .settings-content = render 'provider_details_form', cluster: @cluster, platform: @cluster.platform_kubernetes, update_cluster_url_path: clusterable.cluster_path(@cluster) diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml index c3b881df98d..af4c934fd72 100644 --- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml +++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml @@ -6,5 +6,5 @@ = c.body do = s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link } = c.actions do - %a.gl-button.btn-confirm.text-decoration-none{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' } + = render Pajamas::ButtonComponent.new(variant: :confirm, href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', button_options: { rel: 'noopener noreferrer' }) do = s_("ClusterIntegration|Apply for credit") diff --git a/app/views/dashboard/_activities.html.haml b/app/views/dashboard/_activities.html.haml index 4edb0f324dc..8750b80ccfd 100644 --- a/app/views/dashboard/_activities.html.haml +++ b/app/views/dashboard/_activities.html.haml @@ -1,8 +1,7 @@ .nav-block.activities = render 'shared/event_filter' .controls - = link_to dashboard_projects_path(rss_url_options), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-flex has-tooltip', title: 'Subscribe' do - = sprite_icon('rss', css_class: 'gl-icon') + = render Pajamas::ButtonComponent.new(href: dashboard_projects_path(rss_url_options), icon: 'rss', button_options: { title: _('Subscribe'), aria: { label: _('Subscribe') }, class: 'gl-display-none gl-sm-display-inline-flex' }) .content_list .loading diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml index 1c82b30ed8d..09e2e35c617 100644 --- a/app/views/dashboard/_groups_head.html.haml +++ b/app/views/dashboard/_groups_head.html.haml @@ -3,8 +3,8 @@ - if current_user.can_create_group? .page-title-controls - = link_to _("New group"), new_group_path, class: "gl-button btn btn-confirm", data: { qa_selector: "new_group_button", testid: "new-group-button" } - + = render Pajamas::ButtonComponent.new(href: new_group_path, variant: :confirm, button_options: { data: { qa_selector: "new_group_button", testid: "new-group-button" } }) do + = _("New group") .top-area = gl_tabs_nav({ class: 'gl-flex-grow-1 gl-border-0' }) do = gl_tab_link_to _("Your groups"), dashboard_groups_path diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml index 9c492a0da34..10e653fd427 100644 --- a/app/views/dashboard/_projects_head.html.haml +++ b/app/views/dashboard/_projects_head.html.haml @@ -1,6 +1,3 @@ -- project_tab_filter = local_assigns.fetch(:project_tab_filter, "") -- feature_project_list_filter_bar = Feature.enabled?(:project_list_filter_bar) - = content_for :flash_message do = render 'shared/project_limit' @@ -9,17 +6,13 @@ - if current_user.can_create_project? .page-title-controls - = link_to _("New project"), new_project_path, class: "gl-button btn btn-confirm", data: { qa_selector: 'new_project_button' } + = render Pajamas::ButtonComponent.new(href: new_project_path, variant: :confirm, button_options: { data: { qa_selector: 'new_project_button' } }) do + = _("New project") .top-area - .scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-min-w-0.gl-w-full + .scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-min-w-0 .fade-left= sprite_icon('chevron-lg-left', size: 12) .fade-right= sprite_icon('chevron-lg-right', size: 12) = render 'dashboard/projects_nav' - - unless feature_project_list_filter_bar - .nav-controls - = render 'shared/projects/search_form' - = render 'shared/projects/dropdown' -- if feature_project_list_filter_bar - .project-filters - = render 'shared/projects/search_bar', project_tab_filter: project_tab_filter + .nav-controls + = render 'shared/projects/search_form' diff --git a/app/views/dashboard/_projects_nav.html.haml b/app/views/dashboard/_projects_nav.html.haml index 29c820ddc58..7cbd2fb14ec 100644 --- a/app/views/dashboard/_projects_nav.html.haml +++ b/app/views/dashboard/_projects_nav.html.haml @@ -3,11 +3,11 @@ = gl_tabs_nav({ class: 'scrolling-tabs nav-links gl-display-flex gl-flex-grow-1 gl-w-full nav gl-tabs-nav' }) do = gl_tab_link_to dashboard_projects_path, { item_active: is_your_projects_path, class: 'shortcuts-activity', data: { placement: 'right' } } do - = _("Your projects") + = s_("ProjectList|Yours") = gl_tab_counter_badge(limited_counter_with_delimiter(@total_user_projects_count)) = gl_tab_link_to starred_dashboard_projects_path, { data: { placement: 'right' } } do - = _("Starred projects") + = s_("ProjectList|Starred") = gl_tab_counter_badge(limited_counter_with_delimiter(@total_starred_projects_count)) - = gl_tab_link_to _("Explore projects"), explore_root_path, { item_active: is_explore_projects_path, data: { placement: 'right' } } - = gl_tab_link_to _("Explore topics"), topics_explore_projects_path, { data: { placement: 'right' } } + = gl_tab_link_to s_("ProjectList|Explore"), explore_root_path, { item_active: is_explore_projects_path, data: { placement: 'right' } } + = gl_tab_link_to s_("ProjectList|Topics"), topics_explore_projects_path, { data: { placement: 'right' } } = render_if_exists "dashboard/removed_projects_tab" diff --git a/app/views/dashboard/_snippets_head.html.haml b/app/views/dashboard/_snippets_head.html.haml index be2124fdd7e..5a798c249d1 100644 --- a/app/views/dashboard/_snippets_head.html.haml +++ b/app/views/dashboard/_snippets_head.html.haml @@ -4,7 +4,8 @@ - if current_user && current_user.snippets.any? || @snippets.any? .page-title-controls - if can?(current_user, :create_snippet) - = link_to _("New snippet"), new_snippet_path, class: "gl-button btn btn-confirm", title: _("New snippet") + = render Pajamas::ButtonComponent.new(href: new_snippet_path, variant: :confirm, button_options: { title: _("New snippet") }) do + = _("New snippet") .top-area = gl_tabs_nav({ class: 'gl-border-0' }) do diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml index 79f6bfc866a..5293f685d06 100644 --- a/app/views/dashboard/issues.html.haml +++ b/app/views/dashboard/issues.html.haml @@ -1,6 +1,7 @@ - @hide_top_links = true - page_title _("Issues") - @breadcrumb_link = issues_dashboard_path(assignee_username: current_user.username) +- add_page_specific_style 'page_bundles/issuable_list' - add_page_specific_style 'page_bundles/dashboard' = content_for :meta_tags do = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{current_user.name} issues") @@ -15,10 +16,7 @@ = render 'shared/new_project_item_select', path: 'issues/new', label: _("issue"), with_feature_enabled: 'issues', type: :issues - if ::Feature.enabled?(:vue_issues_dashboard) - .js-issues-dashboard{ data: { calendar_path: url_for(safe_params.merge(calendar_url_options)), - empty_state_svg_path: image_path('illustrations/issue-dashboard_results-without-filter.svg'), - is_signed_in: current_user.present?.to_s, - rss_path: url_for(safe_params.merge(rss_url_options)) } } + .js-issues-dashboard{ data: dashboard_issues_list_data(current_user) } - else .top-area = render 'shared/issuable/nav', type: :issues, display_count: !@no_filters_set diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml index 8a639d08a27..c921375edd1 100644 --- a/app/views/dashboard/merge_requests.html.haml +++ b/app/views/dashboard/merge_requests.html.haml @@ -1,6 +1,7 @@ - @hide_top_links = true - page_title _("Merge requests") - @breadcrumb_link = merge_requests_dashboard_path(assignee_username: current_user.username) +- add_page_specific_style 'page_bundles/issuable_list' = render_dashboard_ultimate_trial(current_user) diff --git a/app/views/dashboard/projects/_nav.html.haml b/app/views/dashboard/projects/_nav.html.haml index 3e39872902d..45e3267813f 100644 --- a/app/views/dashboard/projects/_nav.html.haml +++ b/app/views/dashboard/projects/_nav.html.haml @@ -1,19 +1,4 @@ -- inactive_class = 'btn p-2' -- active_class = 'btn p-2 active' -- project_tab_filter = local_assigns.fetch(:project_tab_filter, "") -- is_explore_trending = project_tab_filter == :explore_trending -- feature_project_list_filter_bar = Feature.enabled?(:project_list_filter_bar) - -.nav-block{ class: ("w-100" if feature_project_list_filter_bar) } - - if feature_project_list_filter_bar - .btn-group.button-filter-group.d-flex.m-0.p-0 - - if project_tab_filter == :explore || is_explore_trending - = link_to s_('DashboardProjects|Trending'), trending_explore_projects_path, class: is_explore_trending ? active_class : inactive_class - = link_to s_('DashboardProjects|All'), explore_projects_path, class: is_explore_trending ? inactive_class : active_class - - else - = link_to s_('DashboardProjects|All'), dashboard_projects_path, class: params[:personal].present? ? inactive_class : active_class - = link_to s_('DashboardProjects|Personal'), filter_projects_path(personal: true), class: params[:personal].present? ? active_class : inactive_class - - else - = gl_tabs_nav do - = gl_tab_link_to s_('DashboardProjects|All'), dashboard_projects_path, { item_active: params[:personal].blank? } - = gl_tab_link_to s_('DashboardProjects|Personal'), filter_projects_path(personal: true), { item_active: params[:personal].present? } +.nav-block + = gl_tabs_nav do + = gl_tab_link_to s_('DashboardProjects|All'), dashboard_projects_path, { item_active: params[:personal].blank? } + = gl_tab_link_to s_('DashboardProjects|Personal'), filter_projects_path(personal: true), { item_active: params[:personal].present? } diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml index 0d9257e659a..f427c347dd3 100644 --- a/app/views/dashboard/projects/index.html.haml +++ b/app/views/dashboard/projects/index.html.haml @@ -12,7 +12,7 @@ = render "projects/last_push" - if show_projects?(@projects, params) = render 'dashboard/projects_head' - = render 'nav' unless Feature.enabled?(:project_list_filter_bar) + = render 'nav' = render 'projects' - else = render "zero_authorized_projects" diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index 47bc8f5c95b..9dfeaa3d07d 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -1,60 +1,66 @@ -%li.todo.gl-hover-border-blue-200.gl-hover-bg-blue-50.gl-hover-cursor-pointer{ class: "todo-#{todo.done? ? 'done' : 'pending'}", id: dom_id(todo), data: { url: todo_target_path(todo) } } - .gl-display-flex.gl-flex-direction-row - .todo-avatar.gl-display-none.gl-sm-display-inline-block - = author_avatar(todo, size: 40) - - .todo-item.flex-fill.gl-overflow-hidden.gl-overflow-x-auto.gl-align-self-center{ data: { qa_selector: "todo_item_container" } } - .todo-title.gl-mb-3.gl-md-mb-0 - - if todo_author_display?(todo) - = todo_target_state_pill(todo) - - %span.title-item.author-name.bold - - if todo.author - = link_to_author(todo, self_added: todo.self_added?) - - else - = _('(removed)') - - %span.title-item.action-name{ data: { qa_selector: "todo_action_name_content" } } - = todo_action_name(todo) - - %span.title-item.todo-label.todo-target-link +%li.todo.gl-hover-border-blue-200.gl-hover-bg-blue-50.gl-hover-cursor-pointer.gl-relative{ class: "todo-#{todo.done? ? 'done' : 'pending'}", id: dom_id(todo) } + .gl-display-flex.gl-flex-direction-column.gl-sm-flex-direction-row.gl-sm-align-items-center + .todo-item.gl-overflow-hidden.gl-overflow-x-auto.gl-align-self-center.gl-w-full{ data: { qa_selector: "todo_item_container" } } + .todo-title.gl-pt-2.gl-pb-3.gl-px-2.gl-md-mb-1.gl-font-sm.gl-text-gray-500 + + = todo_target_state_pill(todo) + + %span.todo-target-title{ data: { qa_selector: "todo_target_title_content" }, :id => dom_id(todo) + "_describer" } + = todo_target_title(todo) + + - if !todo.for_design? && !todo.member_access_requested? + · + + %span + = todo_parent_path(todo) + + %span.todo-label - if todo.target - = todo_target_link(todo) + = link_to todo_target_name(todo), todo_target_path(todo), class: 'todo-target-link gl-text-gray-500! gl-text-decoration-none!', :'aria-describedby' => dom_id(todo) + "_describer", :'aria-label' => todo_target_aria_label(todo) - else = _("(removed)") - %span.title-item.todo-target-title{ data: { qa_selector: "todo_target_title_content" } } - = todo_target_title(todo) - - %span.title-item.todo-project.todo-label - = s_('Todo|at %{todo_parent_path}').html_safe % { todo_parent_path: todo_parent_path(todo) } + .todo-body.gl-mb-2.gl-px-2.gl-display-flex.gl-align-items-flex-start.gl-lg-align-items-center + .todo-avatar.gl-display-none.gl-sm-display-inline-block + = author_avatar(todo, size: 24) + .todo-note + - if todo_author_display?(todo) + .author-name.bold.gl-display-inline + - if todo.author + = link_to_author(todo, self_added: todo.self_added?) + - else + = _('(removed)') - - if todo.self_assigned? - %span.title-item.action-name - = todo_self_addressing(todo) + %span.action-name{ data: { qa_selector: "todo_action_name_content" } }< + = todo_action_name(todo) + - if todo.note.present? + \: + - unless todo.note.present? || todo.self_assigned? + \. - %span.title-item - · + - if todo.self_assigned? + %span.action-name< + = todo_self_addressing(todo) + \. + - if todo.note.present? + %span.action-description.gl-font-style-italic< + = first_line_in_markdown(todo, :body, 100, is_todo: true, project: todo.project, group: todo.group) - %span.title-item.todo-timestamp - #{time_ago_with_tooltip(todo.created_at)} - = todo_due_date(todo) + .todo-timestamp.gl-white-space-nowrap.gl-sm-ml-3.gl-mt-2.gl-mb-2.gl-sm-my-0.gl-px-2.gl-sm-px-0 + %span.todo-timestamp.gl-font-sm.gl-text-gray-500 + = todo_due_date(todo) + #{time_ago_with_tooltip(todo.created_at)} - - if todo.note.present? - .todo-body - .todo-note.break-word - .md - = first_line_in_markdown(todo, :body, 150, project: todo.project, group: todo.group) - .todo-actions.gl-ml-3 + .todo-actions.gl-mr-4.gl-px-2.gl-sm-px-0.gl-sm-mx-0 - if todo.pending? - = link_to dashboard_todo_path(todo), method: :delete, class: 'gl-button gl-bg-gray-50 btn btn-default btn-loading d-flex align-items-center js-done-todo', data: { href: dashboard_todo_path(todo) } do + = render Pajamas::ButtonComponent.new(button_options: { class: 'btn-loading btn-icon gl-display-flex js-done-todo has-tooltip', title: _('Mark as done')}, method: :delete, href: dashboard_todo_path(todo)), 'aria-label' => _('Mark as done') do = gl_loading_icon(inline: true) - = _('Done') - = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'gl-button gl-bg-gray-50 btn btn-default btn-loading d-flex align-items-center js-undo-todo hidden', data: { href: restore_dashboard_todo_path(todo) } do + = sprite_icon('check', css_class: 'js-todo-button-icon') + = render Pajamas::ButtonComponent.new(button_options: { class: 'btn-loading btn-icon gl-display-flex js-undo-todo hidden has-tooltip', title: _('Undo')}, method: :patch, href: restore_dashboard_todo_path(todo)), 'aria-label' => _('Undo') do = gl_loading_icon(inline: true) - = _('Undo') + = sprite_icon('redo', css_class: 'js-todo-button-icon') - else - = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'gl-button gl-bg-gray-50 btn btn-default btn-loading d-flex align-items-center js-add-todo', data: { href: restore_dashboard_todo_path(todo) } do + = render Pajamas::ButtonComponent.new(button_options: { class: 'btn-loading btn-icon gl-display-flex js-add-todo has-tooltip', title: _('Add a to do')}, method: :patch, href: restore_dashboard_todo_path(todo)), 'aria-label' => _('Add a to do') do = gl_loading_icon(inline: true) - = _('Add a to do') + = sprite_icon('todo-add', css_class: 'js-todo-button-icon') diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index deb1ac9e360..c0bd3ee3f0d 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -5,6 +5,7 @@ = render_two_factor_auth_recovery_settings_check = render_dashboard_ultimate_trial(current_user) - add_page_specific_style 'page_bundles/todos' +- add_page_specific_style 'page_bundles/issuable' .page-title-holder.d-flex.align-items-center %h1.page-title.gl-font-size-h-display= _("To-Do List") diff --git a/app/views/devise/shared/_footer.html.haml b/app/views/devise/shared/_footer.html.haml index 10cfc07a719..0744faa148c 100644 --- a/app/views/devise/shared/_footer.html.haml +++ b/app/views/devise/shared/_footer.html.haml @@ -1,9 +1,10 @@ %hr.footer-fixed -.container.footer-container +.container.footer-container.gl-display-flex.gl-justify-content-space-between .footer-links - unless public_visibility_restricted? = link_to _("Explore"), explore_root_path = link_to _("Help"), help_path = link_to _("About GitLab"), "https://#{ApplicationHelper.promo_host}" = link_to _("Community forum"), ApplicationHelper.community_forum, target: '_blank', class: 'text-nowrap', rel: 'noopener noreferrer' + = render 'devise/shared/language_switcher' = footer_message diff --git a/app/views/devise/shared/_language_switcher.html.haml b/app/views/devise/shared/_language_switcher.html.haml new file mode 100644 index 00000000000..4c47e3efd0f --- /dev/null +++ b/app/views/devise/shared/_language_switcher.html.haml @@ -0,0 +1,3 @@ +- return unless ::Feature.enabled?(:preferred_language_switcher) + +.js-language-switcher{ data: { locales: ordered_selectable_locales.to_json } } diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index b9c9c99bf1a..a3a5fe690a7 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -44,7 +44,6 @@ .form-group = f.label :email, class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}" = f.email_field :email, - value: @invite_email, class: 'form-control gl-form-input middle js-validate-email', data: { qa_selector: 'new_user_email_field' }, required: true, diff --git a/app/views/devise/unlocks/new.html.haml b/app/views/devise/unlocks/new.html.haml index abaf169afd5..b9d50e48d05 100644 --- a/app/views/devise/unlocks/new.html.haml +++ b/app/views/devise/unlocks/new.html.haml @@ -1,14 +1,14 @@ = render 'devise/shared/tab_single', tab_title: _('Resend unlock instructions') .login-box .login-body - = form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post, class: 'gl-show-field-errors' }) do |f| + = gitlab_ui_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.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 - = f.submit _('Resend unlock instructions'), class: 'gl-button btn btn-confirm' + = f.submit _('Resend unlock instructions'), pajamas_button: true, class: 'gl-w-full' .clearfix.prepend-top-20 = render 'devise/shared/sign_in_link' diff --git a/app/views/explore/projects/_filter.html.haml b/app/views/explore/projects/_filter.html.haml index f02d30081b6..67a88f3d623 100644 --- a/app/views/explore/projects/_filter.html.haml +++ b/app/views/explore/projects/_filter.html.haml @@ -1,9 +1,7 @@ - has_label = local_assigns.fetch(:has_label, false) -- feature_project_list_filter_bar = Feature.enabled?(:project_list_filter_bar) -- klass = feature_project_list_filter_bar ? 'gl-ml-3 gl-display-flex gl-flex-grow-1 gl-flex-shrink-1' : 'gl-ml-3' - selected = projects_filter_selected(params[:visibility_level]) - if current_user - unless has_label %span.gl-float-left= _("Visibility:") - = gl_redirect_listbox_tag(projects_filter_items, selected, class: klass, data: { right: true }) + = gl_redirect_listbox_tag(projects_filter_items, selected, class: 'gl-ml-3', data: { right: true }) diff --git a/app/views/explore/projects/_nav.html.haml b/app/views/explore/projects/_nav.html.haml index 9d7a6f1ccfb..9119026320a 100644 --- a/app/views/explore/projects/_nav.html.haml +++ b/app/views/explore/projects/_nav.html.haml @@ -7,5 +7,4 @@ .nav-controls - unless current_user = render 'shared/projects/search_form' - = render 'shared/projects/dropdown' = render 'filter' diff --git a/app/views/explore/projects/index.html.haml b/app/views/explore/projects/index.html.haml index ae59d9c728b..9585eb76912 100644 --- a/app/views/explore/projects/index.html.haml +++ b/app/views/explore/projects/index.html.haml @@ -10,5 +10,5 @@ - else = render 'explore/head' -= render 'explore/projects/nav' unless Feature.enabled?(:project_list_filter_bar) && current_user += render 'explore/projects/nav' = render 'projects', projects: @projects diff --git a/app/views/explore/projects/page_out_of_bounds.html.haml b/app/views/explore/projects/page_out_of_bounds.html.haml index c554cce3dc6..ef5ee2c679e 100644 --- a/app/views/explore/projects/page_out_of_bounds.html.haml +++ b/app/views/explore/projects/page_out_of_bounds.html.haml @@ -9,7 +9,7 @@ - else = render 'explore/head' -= render 'explore/projects/nav' unless Feature.enabled?(:project_list_filter_bar) && current_user += render 'explore/projects/nav' .nothing-here-block .svg-content @@ -18,4 +18,5 @@ %h5= _("Maximum page reached") %p= _("Sorry, you have exceeded the maximum browsable page number. Please use the API to explore further.") - = link_to _("Back to page %{number}") % { number: @max_page_number }, request.params.merge(page: @max_page_number), class: 'gl-button btn btn-inverted' + = render Pajamas::ButtonComponent.new(href: request.params.merge(page: @max_page_number)) do + = _("Back to page %{number}") % { number: @max_page_number } diff --git a/app/views/explore/projects/starred.html.haml b/app/views/explore/projects/starred.html.haml index a1f2fea5134..ec7eefea264 100644 --- a/app/views/explore/projects/starred.html.haml +++ b/app/views/explore/projects/starred.html.haml @@ -9,5 +9,5 @@ - else = render 'explore/head' -= render 'explore/projects/nav' unless Feature.enabled?(:project_list_filter_bar) && current_user += render 'explore/projects/nav' = render 'projects', projects: @projects diff --git a/app/views/explore/projects/topic.html.haml b/app/views/explore/projects/topic.html.haml index 76e59a49ed1..7b2c5683482 100644 --- a/app/views/explore/projects/topic.html.haml +++ b/app/views/explore/projects/topic.html.haml @@ -25,7 +25,6 @@ .top-area.gl-pt-2.gl-pb-2 .nav-controls = render 'shared/projects/search_form' - = render 'shared/projects/dropdown' = render 'filter' = render 'projects', projects: @projects diff --git a/app/views/explore/projects/trending.html.haml b/app/views/explore/projects/trending.html.haml index e23f63b0064..8a92ec31b22 100644 --- a/app/views/explore/projects/trending.html.haml +++ b/app/views/explore/projects/trending.html.haml @@ -9,5 +9,5 @@ - else = render 'explore/head' -= render 'explore/projects/nav' unless Feature.enabled?(:project_list_filter_bar) && current_user += render 'explore/projects/nav' = render 'projects', projects: @projects diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml index 0d6c3e74ce8..b62076c23f3 100644 --- a/app/views/groups/_activities.html.haml +++ b/app/views/groups/_activities.html.haml @@ -1,8 +1,7 @@ .nav-block.activities = render 'shared/event_filter', show_group_events: @group.supports_events? .controls - = link_to group_path(@group, rss_url_options), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-flex has-tooltip' , title: _('Subscribe') do - = sprite_icon('rss', css_class: 'gl-icon') + = render Pajamas::ButtonComponent.new(href: group_path(@group, rss_url_options), icon: 'rss', button_options: { class: 'd-none d-sm-inline-flex has-tooltip', title: _('Subscribe') }) .content_list .loading diff --git a/app/views/groups/_group_admin_settings.html.haml b/app/views/groups/_group_admin_settings.html.haml index 687a1fb32bf..0b26db64ffa 100644 --- a/app/views/groups/_group_admin_settings.html.haml +++ b/app/views/groups/_group_admin_settings.html.haml @@ -27,3 +27,13 @@ = f.text_field :two_factor_grace_period, class: 'form-control gl-form-input gl-form-input-sm' %small.form-text.text-gl-muted = _("Time (in hours) that users are allowed to skip forced configuration of two-factor authentication.") + +- if @group.namespace_settings.present? + .form-group.gl-form-group + %legend.col-form-label.col-form-label + = s_('Runners|Runner Registration') + - parent_disabled = Gitlab::CurrentSettings.valid_runner_registrars.exclude?('group') || !@group.all_ancestors_have_runner_registration_enabled? + = f.gitlab_ui_checkbox_component :runner_registration_enabled, + s_('Runners|New group runners can be registered'), + checkbox_options: { checked: @group.runner_registration_enabled && !parent_disabled, disabled: parent_disabled }, + help_text: s_('Runners|Existing runners are not affected. To permit runner registration for all groups, enable this setting in the Admin Area in Settings > CI/CD.').html_safe diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index a82a2e41508..1494990e427 100644 --- a/app/views/groups/_home_panel.html.haml +++ b/app/views/groups/_home_panel.html.haml @@ -3,16 +3,15 @@ - emails_disabled = @group.emails_disabled? .group-home-panel - .row.my-3 - .home-panel-title-row.col-md-12.col-lg-6.d-flex + .gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-sm-flex-direction-column.gl-gap-3.gl-my-5 + .home-panel-title-row.gl-display-flex.gl-align-items-center .avatar-container.rect-avatar.s64.home-panel-avatar.gl-flex-shrink-0.float-none{ class: 'gl-mr-3!' } = 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-font-size-h1.gl-mt-3.gl-mb-2{ itemprop: 'name' } - = @group.name - %span.visibility-icon.gl-text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) } - = visibility_level_icon(@group.visibility_level, options: {class: 'icon'}) + %div + %h1.home-panel-title.gl-font-size-h1.gl-mt-3.gl-mb-2.gl-display-flex{ itemprop: 'name' } + = @group.name + %span.visibility-icon.gl-text-secondary.has-tooltip.gl-ml-2{ data: { container: 'body' }, title: visibility_icon_description(@group) } + = visibility_level_icon(@group.visibility_level, options: {class: 'icon'}) .home-panel-metadata.gl-text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ data: { qa_selector: 'group_id_content' }, itemprop: 'identifier' } - if can?(current_user, :read_group, @group) %span.gl-display-inline-block.gl-vertical-align-middle @@ -22,24 +21,23 @@ %span.gl-ml-3.gl-mb-3 = render 'shared/members/access_request_links', source: @group - .home-panel-buttons.col-md-12.col-lg-6 - - if current_user - .gl-display-flex.gl-flex-wrap.gl-lg-justify-content-end.gl-mx-n2{ data: { testid: 'group-buttons' } } - - if current_user.admin? - = link_to [:admin, @group], class: 'btn btn-default gl-button btn-icon gl-mt-3 gl-mr-2', title: _('View group in admin area'), - data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do - = sprite_icon('admin') - - if @notification_setting - .js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), group_id: @group.id, container_class: 'gl-mx-2 gl-mt-3 gl-vertical-align-top', no_flip: 'true' } } - - if can_create_subgroups - .gl-px-2.gl-sm-w-auto.gl-w-full - = link_to _("New subgroup"), - new_group_path(parent_id: @group.id, anchor: 'create-group-pane'), - class: "btn btn-default gl-button gl-mt-3 gl-sm-w-auto gl-w-full", - data: { qa_selector: 'new_subgroup_button' } - - if can_create_projects - .gl-px-2.gl-sm-w-auto.gl-w-full - = link_to _("New project"), new_project_path(namespace_id: @group.id), class: "btn btn-confirm gl-button gl-mt-3 gl-sm-w-auto gl-w-full", data: { qa_selector: 'new_project_button' } + - if current_user + .home-panel-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-center.gl-flex-wrap.gl-gap-3{ data: { testid: 'group-buttons' } } + - if current_user.admin? + = link_to [:admin, @group], class: 'btn btn-default gl-button btn-icon', title: _('View group in admin area'), + data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = sprite_icon('admin') + - if @notification_setting + .js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), group_id: @group.id, container_class: 'gl-vertical-align-top', no_flip: 'true' } } + - if can_create_subgroups + .gl-sm-w-auto.gl-w-full + = link_to _("New subgroup"), + new_group_path(parent_id: @group.id, anchor: 'create-group-pane'), + class: "btn btn-default gl-button gl-sm-w-auto gl-w-full", + data: { qa_selector: 'new_subgroup_button' } + - if can_create_projects + .gl-sm-w-auto.gl-w-full + = link_to _("New project"), new_project_path(namespace_id: @group.id), class: "btn btn-confirm gl-button gl-sm-w-auto gl-w-full", data: { qa_selector: 'new_project_button' } - if @group.description.present? .group-home-desc.mt-1 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 index d48bf0173a4..4a4bdfc6714 100644 --- a/app/views/groups/_import_group_from_another_instance_panel.html.haml +++ b/app/views/groups/_import_group_from_another_instance_panel.html.haml @@ -1,20 +1,39 @@ +- bulk_imports_disabled = !Gitlab::CurrentSettings.bulk_import_enabled? + = gitlab_ui_form_with url: configure_import_bulk_imports_path(namespace_id: params[:parent_id]), class: 'gl-show-field-errors' do |f| .gl-border-l-solid.gl-border-r-solid.gl-border-t-solid.gl-border-gray-100.gl-border-1.gl-p-5.gl-mt-4 .gl-display-flex.gl-align-items-center %h4.gl-display-flex = s_('GroupsNew|Import groups from another instance of GitLab') = link_to _('History'), history_import_bulk_imports_path, class: 'gl-link gl-ml-auto' - = render Pajamas::AlertComponent.new(dismissible: false, - variant: :warning) do |c| - = c.body do - - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index.md') } - - docs_link_end = '</a>'.html_safe - = s_('GroupsNew|Not all related objects are migrated. %{docs_link_start}More info%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: docs_link_end } + + - if bulk_imports_disabled + = render Pajamas::AlertComponent.new(dismissible: false, variant: :tip) do |c| + = c.body do + = s_('GroupsNew|Importing groups by direct transfer is currently disabled.') + + - if current_user.admin? + - admin_link_start = '<a href="%{url}">'.html_safe % { url: general_admin_application_settings_path(anchor: 'js-visibility-settings') } + - admin_link_end = '</a>'.html_safe + + = s_('GroupsNew|Please %{admin_link_start}enable it in the Admin settings%{admin_link_end}.').html_safe % { admin_link_start: admin_link_start, admin_link_end: admin_link_end } + - else + = s_('GroupsNew|Please ask your Administrator to enable it in the Admin settings.') + + = s_('GroupsNew|Remember to enable it also on the instance you are migrating from.') + - else + = render Pajamas::AlertComponent.new(dismissible: false, + variant: :warning) do |c| + = c.body do + - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index.md') } + - docs_link_end = '</a>'.html_safe + = s_('GroupsNew|Not all related objects are migrated. %{docs_link_start}More info%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: docs_link_end } + %p.gl-mt-3 - = s_('GroupsNew|Provide credentials for another instance of GitLab to import your groups directly.') + = 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', + = f.text_field :bulk_import_gitlab_url, disabled: bulk_imports_disabled, 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', @@ -24,12 +43,13 @@ .gl-font-weight-normal - pat_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path('user/profile/personal_access_tokens') } - short_living_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path('security/token_overview', anchor: 'security-considerations') } - = s_('GroupsNew|Create this in the %{pat_link_start}user settings%{pat_link_end} of the source GitLab instance. For %{short_living_link_start}security reasons%{short_living_link_end}, use a short expiration date when creating the token.').html_safe % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe, short_living_link_start: short_living_link_start, short_living_link_end: '</a>'.html_safe } + = s_('GroupsNew|Create a token with %{code_start}api%{code_end} and %{code_start}read_repository%{code_end} scopes in the %{pat_link_start}user settings%{pat_link_end} of the source GitLab instance. For %{short_living_link_start}security reasons%{short_living_link_end}, set a short expiration date for the token. Keep in mind that large migrations take more time.').html_safe % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe, pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe, short_living_link_start: short_living_link_start, short_living_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, + disabled: bulk_imports_disabled, autocomplete: 'off', title: s_('GroupsNew|Please fill in your personal access token.'), id: 'import_gitlab_token', data: { qa_selector: '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'), pajamas_button: true, data: { qa_selector: 'connect_instance_button' } + = f.submit s_('GroupsNew|Connect instance'), disabled: bulk_imports_disabled, pajamas_button: true, data: { qa_selector: 'connect_instance_button' } diff --git a/app/views/groups/_invite_groups_modal.html.haml b/app/views/groups/_invite_groups_modal.html.haml index 2e11f6cee4f..b8e40460a92 100644 --- a/app/views/groups/_invite_groups_modal.html.haml +++ b/app/views/groups/_invite_groups_modal.html.haml @@ -1,3 +1,3 @@ - return unless can_admin_group_member?(group) -.js-invite-groups-modal{ data: common_invite_group_modal_data(group, GroupMember, 'false') } +.js-invite-groups-modal{ data: { reload_page_on_submit: local_assigns.fetch(:reload_page_on_submit, false).to_s }.merge(common_invite_group_modal_data(group, GroupMember, 'false')) } diff --git a/app/views/groups/_invite_members_modal.html.haml b/app/views/groups/_invite_members_modal.html.haml index 786034fd2e7..bf0e8b627fd 100644 --- a/app/views/groups/_invite_members_modal.html.haml +++ b/app/views/groups/_invite_members_modal.html.haml @@ -2,4 +2,5 @@ .js-invite-members-modal{ data: { is_project: 'false', access_levels: GroupMember.access_level_roles.to_json, + reload_page_on_submit: local_assigns.fetch(:reload_page_on_submit, false).to_s, help_link: help_page_url('user/permissions') }.merge(common_invite_modal_dataset(group)).merge(users_filter_data(group)) } diff --git a/app/views/groups/_new_group_fields.html.haml b/app/views/groups/_new_group_fields.html.haml index 94b0b018084..95990e8937c 100644 --- a/app/views/groups/_new_group_fields.html.haml +++ b/app/views/groups/_new_group_fields.html.haml @@ -30,5 +30,7 @@ = recaptcha_tags nonce: content_security_policy_nonce .row .col-sm-12 - = f.submit submit_label, class: "btn gl-button btn-confirm", data: { qa_selector: 'create_group_button' } - = link_to _('Cancel'), dashboard_groups_path, class: 'btn gl-button btn-default btn-cancel' + = f.submit submit_label, pajamas_button: true, data: { qa_selector: 'create_group_button' } + = render Pajamas::ButtonComponent.new(href: dashboard_groups_path) do + = _('Cancel') + diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index bca1c874cc6..8763912438b 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -13,7 +13,7 @@ = _('Collapse') %p = _('Update your group name, description, avatar, and visibility.') - = link_to s_('Learn more about groups.'), help_page_path('user/group/index') + = link_to _('Learn more about groups.'), help_page_path('user/group/index') .settings-content = render 'groups/settings/general' diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index 4da70c8bf5d..298ed2c0806 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -15,8 +15,8 @@ classes: 'gl-md-w-auto gl-w-full gl-md-ml-3 gl-md-mt-0 gl-mt-3', trigger_source: 'group-members-page', display_text: _('Invite members') } } - = render 'groups/invite_groups_modal', group: @group - = render 'groups/invite_members_modal', group: @group + = render 'groups/invite_groups_modal', group: @group, reload_page_on_submit: true + = render 'groups/invite_members_modal', group: @group, reload_page_on_submit: true = render_if_exists 'groups/group_members/ldap_sync' diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml index 925e7d46f14..6faa4758d66 100644 --- a/app/views/groups/issues.html.haml +++ b/app/views/groups/issues.html.haml @@ -1,8 +1,9 @@ - page_title _('Issues') +- add_page_specific_style 'page_bundles/issuable_list' - add_page_specific_style 'page_bundles/issues_list' = content_for :meta_tags do = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@group.name} issues") -.js-issues-list{ data: group_issues_list_data(@group, current_user) } +.js-issues-list-root{ data: group_issues_list_data(@group, current_user) } - if can?(current_user, :admin_issue, @group) && @group.licensed_feature_available?(:group_bulk_edit) = render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml index 8187dda5471..a03c406acc6 100644 --- a/app/views/groups/labels/index.html.haml +++ b/app/views/groups/labels/index.html.haml @@ -11,7 +11,7 @@ .labels-container.gl-mt-5 - if @labels.any? .text-muted.gl-mb-5 - = _('Labels can be applied to %{features}. Group labels are available for any project within the group.') % { features: issuable_types.to_sentence } + = labels_function_introduction .other-labels %h4= _('Labels') %ul.manage-labels-list.js-other-labels diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml index 6c4a8b53764..92f6c896e7b 100644 --- a/app/views/groups/merge_requests.html.haml +++ b/app/views/groups/merge_requests.html.haml @@ -1,6 +1,7 @@ - @can_bulk_update = can?(current_user, :admin_merge_request, @group) && @group.licensed_feature_available?(:group_bulk_edit) && issuables_count_for_state(:merge_requests, :all) > 0 - page_title _("Merge requests") +- add_page_specific_style 'page_bundles/issuable_list' .top-area = render 'shared/issuable/nav', type: :merge_requests diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml index d4b1c3c27f1..a99d76f99a7 100644 --- a/app/views/groups/milestones/_form.html.haml +++ b/app/views/groups/milestones/_form.html.haml @@ -22,7 +22,9 @@ .form-actions - if @milestone.new_record? = f.submit _('Create milestone'), data: { qa_selector: "create_milestone_button" }, pajamas_button: true - = link_to _("Cancel"), group_milestones_path(@group), class: "btn gl-button btn-cancel" + = render Pajamas::ButtonComponent.new(href: group_milestones_path(@group)) do + = _("Cancel") - else = f.submit _('Update milestone'), pajamas_button: true - = link_to _("Cancel"), group_milestone_path(@group, @milestone), class: "btn gl-button btn-cancel" + = render Pajamas::ButtonComponent.new(href: group_milestone_path(@group, @milestone)) do + = _("Cancel") diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml index 50a1b474504..f49b69f821d 100644 --- a/app/views/groups/milestones/index.html.haml +++ b/app/views/groups/milestones/index.html.haml @@ -9,13 +9,14 @@ = render 'shared/milestones/search_form' = render 'shared/milestones_sort_dropdown' - if can?(current_user, :admin_milestone, @group) - = link_to _('New milestone'), new_group_milestone_path(@group), class: "btn gl-button btn-confirm gl-ml-3", data: { qa_selector: "new_group_milestone_link" } - + = render Pajamas::ButtonComponent.new(href: new_group_milestone_path(@group), variant: :confirm, button_options: { data: { qa_selector: "new_group_milestone_link" }, class: "gl-ml-3" }) do + = _('New milestone') - if @milestones.blank? = render 'shared/empty_states/milestones_tab', learn_more_path: help_page_path('user/project/milestones/index') do - if can?(current_user, :admin_milestone, @group) .text-center - = link_to _('New milestone'), new_group_milestone_path(@group), class: "btn gl-button btn-confirm", data: { qa_selector: "new_group_milestone_link" } + = render Pajamas::ButtonComponent.new(href: new_group_milestone_path(@group), variant: :confirm, button_options: { data: { qa_selector: "new_group_milestone_link" }}) do + = _('New milestone') - else .milestones %ul.content-list @@ -29,4 +30,5 @@ = render 'shared/empty_states/milestones', learn_more_path: help_page_path('user/project/milestones/index') do - if can?(current_user, :admin_milestone, @group) .text-center - = link_to _('New milestone'), new_group_milestone_path(@group), class: "btn gl-button btn-confirm", data: { qa_selector: "new_group_milestone_link" } + = render Pajamas::ButtonComponent.new(href: new_group_milestone_path(@group), variant: :confirm, button_options: { data: { qa_selector: "new_group_milestone_link" }}) do + = _('New milestone') diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml index e7ae54a8879..cae347630ee 100644 --- a/app/views/groups/projects.html.haml +++ b/app/views/groups/projects.html.haml @@ -12,19 +12,19 @@ = _("New project") - c.body do %ul.content-list - - @projects.each do |project| - %li.project-row.gl-align-items-center{ class: 'gl-display-flex!' } + - @projects.each_with_index do |project, idx| + %li.project-row.gl-align-items-center{ class: 'gl-display-flex!', data: { qa_selector: 'project_row_container', qa_index: idx } } .avatar-container.rect-avatar.s40.gl-flex-shrink-0 = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40) .gl-min-w-0.gl-flex-grow-1 .title = link_to project_path(project), class: 'js-prefetch-document' do - %span.project-full-name - %span.namespace-name + %span.project-full-name{ data: { qa_selector: 'project_fullname_content' } } + %span.namespace-name{ data: { qa_selector: 'project_namespace_content' } } - if project.namespace = project.namespace.human_name \/ - %span.project-name + %span.project-name{ data: { qa_selector: 'project_name_content', qa_project_name: project.name } } = project.name %span{ class: visibility_level_color(project.visibility_level) } = visibility_level_icon(project.visibility_level) @@ -38,9 +38,9 @@ = render 'project_badges', project: project .controls.gl-flex-shrink-0.gl-ml-5 - = link_to _('Members'), project_project_members_path(project), id: dom_id(project, :edit), class: "btn gl-button" - = link_to _('Edit'), edit_project_path(project), id: dom_id(project, :edit), class: "btn gl-button" - = render 'delete_project_button', project: project + = link_to _('Members'), project_project_members_path(project), id: dom_id(project, :edit), class: "btn gl-button", data: { qa_selector: 'project_members_button' } + = link_to _('Edit'), edit_project_path(project), id: dom_id(project, :edit), class: "btn gl-button", data: { qa_selector: 'project_edit_button' } + = render 'delete_project_button', project: project, data: { qa_selector: 'project_delete_button' } - if @projects.blank? .nothing-here-block= _("This group has no projects yet") diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml index 6060d697f52..efd2e53e100 100644 --- a/app/views/groups/registry/repositories/index.html.haml +++ b/app/views/groups/registry/repositories/index.html.haml @@ -1,6 +1,6 @@ - page_title _("Container Registry") - @content_class = "limit-container-width" unless fluid_layout -- add_page_startup_graphql_call('container_registry/get_container_repositories', { fullPath: @group.full_path, first: 10, name: nil, isGroupPage: true, sort: nil} ) +- add_page_startup_graphql_call('container_registry/get_container_repositories', { fullPath: @group.full_path, first: 10, name: nil, isGroupPage: true, sort: nil}) %section #js-container-registry{ data: { endpoint: group_container_registries_path(@group), @@ -12,7 +12,6 @@ "registry_host_url_with_port" => escape_once(registry_config.host_port), "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'), - "container_registry_importing_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'tags-temporarily-cannot-be-marked-for-deletion'), "is_admin": current_user&.admin.to_s, is_group_page: "true", "group_path": @group.full_path, diff --git a/app/views/groups/runners/_settings.html.haml b/app/views/groups/runners/_settings.html.haml index 3e5ec3c26e2..24b2469c501 100644 --- a/app/views/groups/runners/_settings.html.haml +++ b/app/views/groups/runners/_settings.html.haml @@ -3,10 +3,3 @@ - if @group.licensed_feature_available?(:stale_runner_cleanup_for_namespace) .gl-mb-5 #stale-runner-cleanup-form{ data: { group_full_path: @group.full_path, stale_timeout_secs: ::Ci::Runner::STALE_TIMEOUT.to_i } } -= render Pajamas::BannerComponent.new(button_text: s_('Runners|Take me there!'), - button_link: group_runners_path(@group), - svg_path: 'illustrations/rocket-launch-md.svg', - close_options: { class: 'gl-display-none' }) do |c| - - c.title do - = s_('Runners|New group runners view') - %p= s_('Runners|The new view gives you more space and better visibility into your fleet of runners.') diff --git a/app/views/groups/runners/index.html.haml b/app/views/groups/runners/index.html.haml index 1146063969b..9ea83397348 100644 --- a/app/views/groups/runners/index.html.haml +++ b/app/views/groups/runners/index.html.haml @@ -1,3 +1,3 @@ - page_title s_('Runners|Runners') -#js-group-runners{ data: group_runners_data_attributes(@group).merge( { group_runners_limited_count: @group_runners_limited_count, registration_token: @group_runner_registration_token } ) } +#js-group-runners{ data: group_runners_data_attributes(@group).merge({ group_runners_limited_count: @group_runners_limited_count, registration_token: @group_runner_registration_token }) } diff --git a/app/views/groups/settings/_export.html.haml b/app/views/groups/settings/_export.html.haml index 66c1341fb15..5d79d0f8e79 100644 --- a/app/views/groups/settings/_export.html.haml +++ b/app/views/groups/settings/_export.html.haml @@ -25,10 +25,10 @@ %li= _('Runner tokens') %li= _('SAML discovery tokens') - if group.export_file_exists? - = link_to _('Download export'), download_export_group_path(group), - rel: 'nofollow', method: :get, class: 'btn gl-button btn-default', data: { qa_selector: 'download_export_link' } - = link_to _('Regenerate export'), export_group_path(group), - method: :post, class: 'btn gl-button btn-default', data: { qa_selector: 'regenerate_export_group_link' } + = render Pajamas::ButtonComponent.new(href: download_export_group_path(group), button_options: { rel: 'nofollow', data: { method: :get, qa_selector: 'download_export_link' } }) do + = _('Download export') + = render Pajamas::ButtonComponent.new(href: export_group_path(group), button_options: { data: { method: :post, qa_selector: 'regenerate_export_group_link' } }) do + = _('Regenerate export') - else - = link_to _('Export group'), export_group_path(group), - method: :post, class: 'btn gl-button btn-default', data: { qa_selector: 'export_group_link' } + = render Pajamas::ButtonComponent.new(href: export_group_path(group), button_options: { data: { method: :post, qa_selector: 'export_group_link' } }) do + = _('Export group') diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml index be9d2c45885..658109fde64 100644 --- a/app/views/groups/settings/_general.html.haml +++ b/app/views/groups/settings/_general.html.haml @@ -32,4 +32,4 @@ = link_to s_('Groups|Remove avatar'), group_avatar_path(@group.to_param), aria: { label: s_('Groups|Remove avatar') }, data: { confirm: s_('Groups|Avatar will be removed. Are you sure?'), 'confirm-btn-variant': 'danger' }, method: :delete, class: 'gl-button btn btn-danger-secondary' .form-group.gl-form-group = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group - = f.submit s_('Groups|Save changes'), class: 'btn gl-button btn-confirm js-dirty-submit', data: { qa_selector: 'save_name_visibility_settings_button' } + = f.submit s_('Groups|Save changes'), pajamas_button: true, class: 'js-dirty-submit', data: { qa_selector: 'save_name_visibility_settings_button' } diff --git a/app/views/groups/settings/_git_access_protocols.html.haml b/app/views/groups/settings/_git_access_protocols.html.haml index df798db79ad..01e8536c7ad 100644 --- a/app/views/groups/settings/_git_access_protocols.html.haml +++ b/app/views/groups/settings/_git_access_protocols.html.haml @@ -1,6 +1,6 @@ - if group.root? && Feature.enabled?(:group_level_git_protocol_control, group) .form-group - = f.label s_('Enabled Git access protocols'), class: 'label-bold' + = f.label _('Enabled Git access protocols'), class: 'label-bold' = f.select :enabled_git_access_protocol, options_for_select(enabled_git_access_protocol_options_for_group, group.enabled_git_access_protocol), {}, class: 'form-control', data: { qa_selector: 'enabled_git_access_protocol_dropdown' }, disabled: !::Gitlab::CurrentSettings.enabled_git_access_protocol.blank? - if !::Gitlab::CurrentSettings.enabled_git_access_protocol.blank? .form-text.text-muted diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index e35c0341ec0..a18789b52a3 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -52,4 +52,4 @@ checkbox_options: { checked: @group.crm_enabled? }, help_text: s_('GroupSettings|Organizations and contacts can be created and associated with issues.') - = f.submit _('Save changes'), class: 'btn gl-button btn-confirm gl-mt-3 js-dirty-submit', data: { qa_selector: 'save_permissions_changes_button' } + = f.submit _('Save changes'), pajamas_button: true, class: 'gl-mt-3 js-dirty-submit', data: { qa_selector: 'save_permissions_changes_button' } diff --git a/app/views/groups/settings/_remove_button.html.haml b/app/views/groups/settings/_remove_button.html.haml index df978f3cb96..cb05076b39d 100644 --- a/app/views/groups/settings/_remove_button.html.haml +++ b/app/views/groups/settings/_remove_button.html.haml @@ -1,8 +1,8 @@ - remove_form_id = local_assigns.fetch(:remove_form_id, nil) -- if group.paid? +- if group.prevent_delete? = render Pajamas::AlertComponent.new(dismissible: false, alert_options: { class: 'gl-mb-5', data: { testid: 'group-has-linked-subscription-alert' }}) do |c| = c.body do - = html_escape(_("This group can't be removed because it is linked to a subscription. To remove this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe } + = html_escape(_("This group can't be removed because it is linked to a subscription. To remove this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/gitlab_com/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe } .js-confirm-danger{ data: group_settings_confirm_modal_data(group, remove_form_id) } diff --git a/app/views/groups/settings/_transfer.html.haml b/app/views/groups/settings/_transfer.html.haml index e01d703206c..3c76e8a864a 100644 --- a/app/views/groups/settings/_transfer.html.haml +++ b/app/views/groups/settings/_transfer.html.haml @@ -1,7 +1,7 @@ - form_id = "transfer-group-form" - initial_data = { button_text: s_('GroupSettings|Transfer group'), group_name: @group.name, group_id: @group.id, target_form_id: form_id, is_paid_group: group.paid?.to_s } -.sub-section +.sub-section{ data: { qa_selector: 'transfer_group_content' } } %h4.warning-title= s_('GroupSettings|Transfer group') %p= _('Transfer group to another parent group.') = form_for group, url: transfer_group_path(group), method: :put, html: { id: form_id, class: 'js-group-transfer-form' } do |f| @@ -15,5 +15,5 @@ - if group.paid? = render Pajamas::AlertComponent.new(dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c| = c.body do - = html_escape(_("This group can't be transferred because it is linked to a subscription. To transfer this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe } + = html_escape(_("This group can't be transferred because it is linked to a subscription. To transfer this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/gitlab_com/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe } .js-transfer-group-form{ data: initial_data } diff --git a/app/views/groups/settings/applications/index.html.haml b/app/views/groups/settings/applications/index.html.haml index 96f834bd271..95bf2151bda 100644 --- a/app/views/groups/settings/applications/index.html.haml +++ b/app/views/groups/settings/applications/index.html.haml @@ -1,4 +1,5 @@ - page_title _("Group applications") +- add_page_specific_style 'page_bundles/settings' = render 'shared/doorkeeper/applications/index', oauth_applications_enabled: user_oauth_applications?, diff --git a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml index a55ccd94974..06cb9893196 100644 --- a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml +++ b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml @@ -13,4 +13,4 @@ help_text: '%{help_text} %{learn_more_link}'.html_safe % { help_text: help_text, learn_more_link: learn_more_link }, checkbox_options: { checked: group.auto_devops_enabled? } - = f.submit _('Save changes'), class: 'btn gl-button btn-confirm gl-mt-5' + = f.submit _('Save changes'), class: 'gl-mt-5', pajamas_button: true diff --git a/app/views/groups/settings/ci_cd/_form.html.haml b/app/views/groups/settings/ci_cd/_form.html.haml index 89e353b94b0..d31d22c61be 100644 --- a/app/views/groups/settings/ci_cd/_form.html.haml +++ b/app/views/groups/settings/ci_cd/_form.html.haml @@ -7,5 +7,5 @@ = f.number_field :max_artifacts_size, class: 'form-control' %p.form-text.text-muted = _("The maximum file size in megabytes for individual job artifacts.") - = link_to s_('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size'), target: '_blank', rel: 'noopener noreferrer' = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/groups/settings/repository/_default_branch.html.haml b/app/views/groups/settings/repository/_default_branch.html.haml index 844a5f890a4..e8aa809a6ca 100644 --- a/app/views/groups/settings/repository/_default_branch.html.haml +++ b/app/views/groups/settings/repository/_default_branch.html.haml @@ -21,4 +21,4 @@ = render 'groups/settings/default_branch_protection', f: f, group: @group = f.hidden_field :redirect_target, value: "repository_settings" - = f.submit _('Save changes'), class: 'btn gl-button btn-confirm' + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/groups/usage_quotas/index.html.haml b/app/views/groups/usage_quotas/index.html.haml new file mode 100644 index 00000000000..a8c1071b876 --- /dev/null +++ b/app/views/groups/usage_quotas/index.html.haml @@ -0,0 +1,7 @@ +- page_title s_("UsageQuota|Usage") + +.gl-alert.gl-alert-no-icon.gl-alert-info.gl-mt-6 + %h2.gl-alert-title + Development + .gl-alert-content + Placeholder for usage quotas Vue app diff --git a/app/views/help/index.html.haml b/app/views/help/index.html.haml index 8c74aac5ef5..b3f9d538e83 100644 --- a/app/views/help/index.html.haml +++ b/app/views/help/index.html.haml @@ -11,7 +11,7 @@ %span= link_to_version - if show_version_check? %span.gl-mt-5.gl-mb-3.gl-ml-3 - .js-gitlab-version-check-badge{ data: { "size": "lg", "actionable": "true" } } + .js-gitlab-version-check-badge{ data: { "size": "lg", "actionable": "true", "version": gitlab_version_check.to_json } } %hr - unless Gitlab::CurrentSettings.help_page_hide_commercial_content? diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml index 95c15612adf..b18b5f1574b 100644 --- a/app/views/ide/_show.html.haml +++ b/app/views/ide/_show.html.haml @@ -1,13 +1,10 @@ -- @body_class = 'ide-layout' - page_title _('IDE') -- add_page_specific_style 'page_bundles/build' -- add_page_specific_style 'page_bundles/ide' +- unless use_new_web_ide? + - add_page_specific_style 'page_bundles/build' + - add_page_specific_style 'page_bundles/ide' -- content_for :prefetch_asset_tags do - - webpack_preload_asset_tag('monaco') + - content_for :prefetch_asset_tags do + - webpack_preload_asset_tag('monaco') -#ide.ide-loading{ data: ide_data } - .text-center - = gl_loading_icon(size: 'md') - %h2.clgray= _('Loading the GitLab IDE...') += render partial: 'shared/ide_root', locals: { data: ide_data, loading_text: _('Loading the GitLab IDE...') } diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml index e92db09aaf1..4d2186a1352 100644 --- a/app/views/import/_githubish_status.html.haml +++ b/app/views/import/_githubish_status.html.haml @@ -4,6 +4,7 @@ - filterable = local_assigns.fetch(:filterable, true) - paginatable = local_assigns.fetch(:paginatable, false) - default_namespace_path = (local_assigns[:default_namespace] || current_user.namespace).full_path +- cancel_path = local_assigns.fetch(:cancel_path, nil) - provider_title = Gitlab::ImportSources.title(local_assigns.fetch(:provider)) - optional_stages = local_assigns.fetch(:optional_stages, []) @@ -13,11 +14,11 @@ #import-projects-mount-element{ data: { provider: provider, provider_title: provider_title, can_select_namespace: current_user.can_select_namespace?.to_s, ci_cd_only: has_ci_cd_only_params?.to_s, - namespaces_path: import_available_namespaces_path, repos_path: url_for([:status, :import, provider, { format: :json }]), jobs_path: url_for([:realtime_changes, :import, provider, { format: :json }]), default_target_namespace: default_namespace_path, import_path: url_for([:import, provider, { format: :json }]), + cancel_path: cancel_path, filterable: filterable.to_s, paginatable: paginatable.to_s, optional_stages: optional_stages.to_json }.merge(extra_data) } diff --git a/app/views/import/bulk_imports/status.html.haml b/app/views/import/bulk_imports/status.html.haml index 1c8de23f28f..e1547920708 100644 --- a/app/views/import/bulk_imports/status.html.haml +++ b/app/views/import/bulk_imports/status.html.haml @@ -3,7 +3,6 @@ - page_title _('Import groups') #import-groups-mount-element{ data: { status_path: status_import_bulk_imports_path(format: :json), - available_namespaces_path: import_available_namespaces_path(format: :json), default_target_namespace: @namespace&.id, create_bulk_import_path: import_bulk_imports_path(format: :json), jobs_path: realtime_changes_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 25afe9a7b1b..4a9f8be35c3 100644 --- a/app/views/import/github/status.html.haml +++ b/app/views/import/github/status.html.haml @@ -10,4 +10,5 @@ = render 'import/githubish_status', provider: 'github', paginatable: paginatable, default_namespace: @namespace, + cancel_path: cancel_import_github_path, optional_stages: Gitlab::GithubImport::Settings.stages_array diff --git a/app/views/import/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml index 42a9d2c3136..079123e989e 100644 --- a/app/views/import/gitlab_projects/new.html.haml +++ b/app/views/import/gitlab_projects/new.html.haml @@ -21,5 +21,8 @@ = file_field_tag :file, class: '' .row .form-actions.col-sm-12 - = submit_tag _('Import project'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'import_project_button' } - = link_to _('Cancel'), new_project_path, class: 'gl-button btn btn-default btn-cancel' + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { class: 'gl-mr-2', data: { qa_selector: 'import_project_button' }}) do + = _('Import project') + = render Pajamas::ButtonComponent.new(href: new_project_path) do + = _('Cancel') + diff --git a/app/views/import/manifest/_form.html.haml b/app/views/import/manifest/_form.html.haml index 096d2543502..a35e9ea3fcf 100644 --- a/app/views/import/manifest/_form.html.haml +++ b/app/views/import/manifest/_form.html.haml @@ -2,11 +2,14 @@ .form-group = label_tag :group_id, nil, class: 'label-bold' do = _('Group') - .input-group - .input-group-prepend.has-tooltip{ title: root_url } - .input-group-text - = root_url - = select_tag :group_id, namespaces_options(params[:namespace_id], display_path: true, groups_only: true), { class: 'select2 js-select-namespace' } + .input-group.gl-max-w-62 + - namespace_id = namespace_id_from(params) || current_user.manageable_groups(include_groups_with_developer_maintainer_access: true)&.first&.id + - namespace_full_path = GroupFinder.new(current_user).execute(id: namespace_id)&.full_path + .js-vue-new-project-url-select{ data: { namespace_full_path: namespace_full_path, + namespace_id: namespace_id , + input_id: 'group_id', + input_name: 'group_id', + root_url: root_url } } .form-text.text-muted = _('Choose the top-level group for your repository imports.') @@ -19,5 +22,8 @@ = link_to sprite_icon('question-o'), help_page_path('user/project/import/manifest') .gl-mb-3 - = submit_tag _('List available repositories'), class: 'gl-button btn btn-confirm' - = link_to _('Cancel'), new_project_path, class: 'gl-button btn btn-default btn-cancel' + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm) do + = _('List available repositories') + + = render Pajamas::ButtonComponent.new(href: new_project_path) do + = _('Cancel') diff --git a/app/views/import/shared/_new_project_form.html.haml b/app/views/import/shared/_new_project_form.html.haml index 16526382f42..6000612a285 100644 --- a/app/views/import/shared/_new_project_form.html.haml +++ b/app/views/import/shared/_new_project_form.html.haml @@ -2,20 +2,23 @@ .form-group.project-name.col-sm-12 = label_tag :name, _('Project name'), class: 'label-bold' = text_field_tag :name, @name, placeholder: "My awesome project", class: "js-project-name form-control gl-form-input input-lg", autofocus: true, required: true, aria: { required: true }, data: { qa_selector: 'project_name_field' } - .form-group.col-12.col-sm-6 + .form-group.col-12.col-sm-6.gl-pr-0 = label_tag :namespace_id, _('Project URL'), class: 'label-bold' - .form-group - .input-group.gl-flex-nowrap - - if current_user.can_select_namespace? - .input-group-prepend.flex-shrink-0.has-tooltip{ title: root_url } - .input-group-text - = root_url - = select_tag :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), class: 'select2 js-select-namespace block-truncated' - - else - .input-group-prepend.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' } - .input-group-text.border-0 - #{user_url(current_user.username)}/ - = hidden_field_tag :namespace_id, current_user.namespace_id + .input-group.gl-flex-nowrap + - if current_user.can_select_namespace? + - namespace_id = namespace_id_from(params) + .js-vue-new-project-url-select{ data: { namespace_full_path: GroupFinder.new(current_user).execute(id: namespace_id)&.full_path || current_user.namespace.full_path, + namespace_id: namespace_id || current_user.namespace_id, + input_id: 'namespace_id', + input_name: 'namespace_id', + root_url: root_url, + user_namespace_id: current_user.namespace_id } } + - else + .input-group-prepend.static-namespace.flex-shrink-0.has-tooltip{ title: user_url(current_user.username) + '/' } + .input-group-text.border-0 + #{user_url(current_user.username)}/ + = hidden_field_tag :namespace_id, current_user.namespace_id + .gl-align-self-center.gl-pl-5 / .form-group.col-12.col-sm-6.project-path = label_tag :path, _('Project slug'), class: 'label-bold' = text_field_tag :path, @path, placeholder: "my-awesome-project", class: "js-path-name form-control gl-form-input", required: true, aria: { required: true }, data: { qa_selector: 'project_slug_field' } diff --git a/app/views/invites/show.html.haml b/app/views/invites/show.html.haml index c1ee12bb6c8..5f65405c8bc 100644 --- a/app/views/invites/show.html.haml +++ b/app/views/invites/show.html.haml @@ -17,8 +17,10 @@ = html_escape(_("You have been invited by %{link_to_inviter} to join %{source_name} %{strong_open}%{link_to_source}%{strong_close} as %{role}")) % { link_to_inviter: link_to_inviter, source_name: @invite_details[:title], strong_open: '<strong>'.html_safe, link_to_source: link_to_source, strong_close: '</strong>'.html_safe, role: @member.human_access } .actions - = link_to _("Accept invitation"), accept_invite_url(@token), method: :post, class: "btn gl-button btn-confirm" - = link_to _("Decline"), decline_invite_url(@token), method: :post, class: "btn gl-button btn-danger gl-ml-3" + = render Pajamas::ButtonComponent.new(variant: :confirm, method: :post, href: accept_invite_url(@token)) do + = _("Accept invitation") + = render Pajamas::ButtonComponent.new(variant: :danger, method: :post, href: decline_invite_url(@token), button_options: { class: 'gl-ml-3' }) do + = _("Decline") - else %p diff --git a/app/views/jira_connect/users/show.html.haml b/app/views/jira_connect/users/show.html.haml index 569c4587f14..5db6cb44ff6 100644 --- a/app/views/jira_connect/users/show.html.haml +++ b/app/views/jira_connect/users/show.html.haml @@ -11,7 +11,10 @@ = s_('JiraService|You can now close this window and%{br}return to the GitLab for Jira application.').html_safe % { br: '<br>'.html_safe } - if @jira_app_link - %p= link_to s_('Integrations|Return to GitLab for Jira'), @jira_app_link, class: 'gl-button btn btn-confirm' + %p + = render Pajamas::ButtonComponent.new(href: @jira_app_link, variant: :confirm) do + = s_('Integrations|Return to GitLab for Jira') + %p= link_to _('Sign out'), destroy_user_session_path, method: :post diff --git a/app/views/layouts/_google_tag_manager_head.html.haml b/app/views/layouts/_google_tag_manager_head.html.haml index 97e118aba93..21b9a604a35 100644 --- a/app/views/layouts/_google_tag_manager_head.html.haml +++ b/app/views/layouts/_google_tag_manager_head.html.haml @@ -20,6 +20,17 @@ 'wait_for_update': 500 }); + window.geofeed = (options) => { + dataLayer.push({ + 'event' : 'OneTrustCountryLoad', + 'oneTrustCountryId': options.country.toString() + }) + } + + const json = document.createElement('script'); + json.setAttribute('src', 'https://geolocation.onetrust.com/cookieconsentpub/v1/geo/location/geofeed'); + document.head.appendChild(json); + - if Feature.enabled?(:gtm_nonce, type: :ops) = javascript_tag nonce: content_security_policy_nonce do :plain diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 2ac926a7fc3..ea2f452b9e2 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -18,7 +18,13 @@ = favicon_link_tag favicon, id: 'favicon', data: { original_href: favicon }, type: 'image/png' - = render 'layouts/startup_css', { startup_filename: local_assigns.fetch(:startup_filename, nil) } + - if startup_css_enabled? + = render 'layouts/startup_css', { startup_filename: local_assigns.fetch(:startup_filename, nil) } + - else + - diffs_colors = user_diffs_colors + = stylesheet_link_tag "themes/#{user_application_theme_css_filename}" if user_application_theme_css_filename + = render 'layouts/diffs_colors_css', diffs_colors if diffs_colors.present? || request.path == profile_preferences_path + - if user_application_theme == 'gl-dark' %meta{ name: 'color-scheme', content: 'dark light' } = stylesheet_link_tag_defer "application_dark" @@ -31,16 +37,22 @@ = stylesheet_link_tag "disable_animations", media: "all" if Rails.env.test? || Gitlab.config.gitlab['disable_animations'] = stylesheet_link_tag "test_environment", media: "all" if Rails.env.test? + = stylesheet_link_tag_defer "fonts" if use_new_fonts? + = stylesheet_link_tag_defer "highlight/themes/#{user_color_scheme}" - = render 'layouts/startup_css_activation' + - if startup_css_enabled? + = render 'layouts/startup_css_activation' = stylesheet_link_tag 'performance_bar' if performance_bar_enabled? = Gon::Base.render_data(nonce: content_security_policy_nonce) = render_if_exists 'layouts/header/translations' - = webpack_bundle_tag "sentry" if Gitlab.config.sentry.enabled + - if Feature.enabled?(:enable_new_sentry_clientside_integration, current_user) && Gitlab::CurrentSettings.sentry_enabled + = webpack_bundle_tag 'sentry' + - elsif Gitlab.config.sentry.enabled + = webpack_bundle_tag 'legacy_sentry' = webpack_bundle_tag 'performance_bar' if performance_bar_enabled? = yield :page_specific_javascripts diff --git a/app/views/layouts/_loading_hints.html.haml b/app/views/layouts/_loading_hints.html.haml index b3bb474ea43..b1d1447ae2a 100644 --- a/app/views/layouts/_loading_hints.html.haml +++ b/app/views/layouts/_loading_hints.html.haml @@ -13,3 +13,9 @@ = preload_link_tag(path_to_stylesheet("highlight/themes/#{user_color_scheme}"), crossorigin: css_crossorigin) - if Gitlab::Tracking.enabled? && Gitlab::Tracking.collector_hostname %link{ rel: 'preconnect', href: "https://#{Gitlab::Tracking.collector_hostname}", crossorigin: '' } + - if use_new_fonts? + -# Do not use preload_link_tag for fonts, to work around Firefox double-fetch bug. + -# See https://github.com/web-platform-tests/wpt/pull/36930 + %link{ rel: 'preload', href: font_path('gitlab-sans/GitLabSans.woff2'), as: 'font', crossorigin: css_crossorigin } + %link{ rel: 'preload', href: font_path('jetbrains-mono/JetBrainsMono.woff2'), as: 'font', crossorigin: css_crossorigin } + = preload_link_tag(path_to_stylesheet('fonts'), crossorigin: css_crossorigin) diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index d668399b408..bb1d051f71f 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -14,6 +14,7 @@ = dispensable_render "layouts/header/registration_enabled_callout" = dispensable_render "layouts/nav/classification_level_banner" = yield :flash_message + = dispensable_render "shared/gitlab_version/security_patch_upgrade_alert" = dispensable_render "shared/service_ping_consent" = dispensable_render_if_exists "layouts/header/ee_subscribable_banner" = dispensable_render_if_exists "layouts/header/seat_count_alert" diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index 0350dc82e46..daf2c582de2 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -29,7 +29,7 @@ = hidden_field_tag :scope, search_context.scope = hidden_field_tag :search_code, search_context.code_search? - - ref = search_context.ref if can?(current_user, :download_code, search_context.project) + - ref = search_context.ref if can?(current_user, :read_code, search_context.project) = hidden_field_tag :snippets, search_context.for_snippets? = hidden_field_tag :repository_ref, ref = hidden_field_tag :nav_source, 'navbar' diff --git a/app/views/layouts/group_settings.html.haml b/app/views/layouts/group_settings.html.haml index c4e5e811280..60eeb9a4602 100644 --- a/app/views/layouts/group_settings.html.haml +++ b/app/views/layouts/group_settings.html.haml @@ -1,5 +1,6 @@ - page_title _("Settings") - nav "group" +- add_page_specific_style 'page_bundles/settings' - enable_search_settings locals: { container_class: 'gl-my-5' } = render template: "layouts/group" diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index 00e7a0567da..8363d424c1b 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -46,6 +46,10 @@ %li.d-md-none = link_to _("Switch to GitLab Next"), Gitlab::Saas.canary_toggle_com_url + - if Feature.enabled?(:super_sidebar_nav, current_user) + %li.divider + .js-new-nav-toggle{ data: { enabled: current_user.use_new_navigation.to_s, endpoint: profile_preferences_url} } + - if current_user_menu?(:sign_out) %li.divider %li diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 47d8f5a447f..558af352ae9 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -54,7 +54,7 @@ = sprite_icon('issues') - issues_count = assigned_issuables_count(:issues) = gl_badge_tag({ size: :sm, variant: :success }, { class: "gl-ml-n2 #{'gl-display-none' if issues_count == 0}", "aria-label": n_("%d assigned issue", "%d assigned issues", issues_count) % issues_count }) do - = number_with_delimiter(issues_count) + = assigned_open_issues_count_text - if header_link?(:merge_requests) = nav_link(path: 'dashboard#merge_requests', html_options: { class: "user-counter dropdown" }) do - top_level_link = assigned_mrs_dashboard_path @@ -119,12 +119,12 @@ = render 'layouts/header/current_user_dropdown' - if has_impersonation_link %li.nav-item.impersonation.ml-0 - = link_to admin_impersonation_path, class: 'nav-link impersonation-btn', method: :delete, title: _('Stop impersonation'), aria: { label: _('Stop impersonation') }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body', qa_selector: 'stop_impersonation_link' } do - = sprite_icon('incognito', size: 18) + = render Pajamas::ButtonComponent.new(href: admin_impersonation_path, icon: 'incognito', button_options: { title: _('Stop impersonation'), class: 'impersonation-btn', aria: { label: _('Stop impersonation') }, data: { method: :delete, toggle: 'tooltip', placement: 'bottom', container: 'body', qa_selector: 'stop_impersonation_link' } }) - if header_link?(:sign_in) - if Gitlab.com? %li.nav-item.gl-display-none.gl-sm-display-block - = link_to _('Sign up now'), new_user_registration_path, class: 'gl-button btn btn-default btn-sign-in' + = render Pajamas::ButtonComponent.new(href: new_user_registration_path) do + = _('Sign up now') %li.nav-item.gl-display-none.gl-sm-display-block = link_to _('Login'), new_session_path(:user, redirect_to_referer: 'yes') = render 'layouts/header/sign_in_register_button', class: 'gl-sm-display-none' diff --git a/app/views/layouts/header/_gitlab_version.html.haml b/app/views/layouts/header/_gitlab_version.html.haml index 2315caa5fe8..581d4d498e1 100644 --- a/app/views/layouts/header/_gitlab_version.html.haml +++ b/app/views/layouts/header/_gitlab_version.html.haml @@ -17,4 +17,4 @@ %span.gl-font-sm.gl-text-gray-500 #{Gitlab.version_info.major}.#{Gitlab.version_info.minor} %span.gl-ml-2 - .js-gitlab-version-check-badge{ data: { "size": "sm" } } + .js-gitlab-version-check-badge{ data: { "size": "sm", "version": gitlab_version_check.to_json } } diff --git a/app/views/layouts/header/_marketing_links.html.haml b/app/views/layouts/header/_marketing_links.html.haml index 24069de394d..c33229e4ec4 100644 --- a/app/views/layouts/header/_marketing_links.html.haml +++ b/app/views/layouts/header/_marketing_links.html.haml @@ -6,29 +6,29 @@ .dropdown-menu %ul %li - = link_to 'https://about.gitlab.com/stages-devops-lifecycle/' do + = link_to Gitlab::Utils.append_path(promo_url, 'stages-devops-lifecycle') do = s_('LoggedOutMarketingHeader|GitLab: the DevOps platform') %li = link_to explore_root_path do = s_('LoggedOutMarketingHeader|Explore GitLab') %li - = link_to 'https://about.gitlab.com/install/' do + = link_to Gitlab::Utils.append_path(promo_url, 'install') do = s_('LoggedOutMarketingHeader|Install GitLab') %li - = link_to 'https://about.gitlab.com/is-it-any-good/' do + = link_to Gitlab::Utils.append_path(promo_url, 'is-it-any-good') do = s_('LoggedOutMarketingHeader|How GitLab compares') %li - = link_to 'https://about.gitlab.com/get-started/' do + = link_to Gitlab::Utils.append_path(promo_url, 'get-started') do = s_('LoggedOutMarketingHeader|Get started') %li - = link_to 'https://docs.gitlab.com/' do + = link_to Gitlab::Saas::doc_url do = s_('LoggedOutMarketingHeader|GitLab docs') %li - = link_to 'https://about.gitlab.com/learn/' do + = link_to Gitlab::Utils.append_path(promo_url, 'learn') do = s_('LoggedOutMarketingHeader|GitLab Learn') %li.gl-mr-3 - = link_to 'https://about.gitlab.com/pricing/' do + = link_to Gitlab::Utils.append_path(promo_url, 'pricing') do = s_('LoggedOutMarketingHeader|Pricing') %li.gl-mr-3 - = link_to 'https://about.gitlab.com/sales/' do + = link_to Gitlab::Utils.append_path(promo_url, 'sales') do = s_('LoggedOutMarketingHeader|Talk to an expert') diff --git a/app/views/layouts/header/_registration_enabled_callout.html.haml b/app/views/layouts/header/_registration_enabled_callout.html.haml index dd3d14a5678..52c39fce961 100644 --- a/app/views/layouts/header/_registration_enabled_callout.html.haml +++ b/app/views/layouts/header/_registration_enabled_callout.html.haml @@ -1,17 +1,17 @@ - return unless show_registration_enabled_user_callout? -= render Pajamas::AlertComponent.new(title: _('Anyone can register for an account.'), += render Pajamas::AlertComponent.new(title: _('Check your sign-up restrictions'), variant: :warning, alert_options: { class: 'js-registration-enabled-callout', data: { feature_id: Users::CalloutsHelper::REGISTRATION_ENABLED_CALLOUT, dismiss_endpoint: callouts_path }}, close_button_options: { data: { testid: 'close-registration-enabled-callout' }}) do |c| = c.body do - = _('Only allow anyone to register for accounts on GitLab instances that you intend to be used by anyone. Allowing anyone to register makes GitLab instances more vulnerable.') + = _("Your GitLab instance allows anyone to register for an account, which is a security risk on public-facing GitLab instances. You should deactivate new sign ups if public users aren't expected to register for an account.") = c.actions do = link_to general_admin_application_settings_path(anchor: 'js-signup-settings'), class: 'btn gl-alert-action btn-confirm btn-md gl-button' do %span.gl-button-text - = _('Turn off') + = _('Deactivate') %button.btn.gl-alert-action.btn-default.btn-md.gl-button.js-close %span.gl-button-text = _('Acknowledge') diff --git a/app/views/layouts/header/_sign_in_register_button.html.haml b/app/views/layouts/header/_sign_in_register_button.html.haml index 992e8785251..cadb7cfe683 100644 --- a/app/views/layouts/header/_sign_in_register_button.html.haml +++ b/app/views/layouts/header/_sign_in_register_button.html.haml @@ -3,4 +3,5 @@ %li.nav-item{ class: top_class } %div - sign_in_text = allow_signup? ? _('Sign in / Register') : _('Sign in') - = link_to sign_in_text, new_session_path(:user, redirect_to_referer: 'yes'), class: 'gl-button btn btn-default btn-sign-in' + = render Pajamas::ButtonComponent.new(href: new_session_path(:user, redirect_to_referer: 'yes'), button_options: { class: 'btn-sign-in'}) do + = sign_in_text diff --git a/app/views/layouts/jira_connect.html.haml b/app/views/layouts/jira_connect.html.haml index 6acd7799875..80bbe578510 100644 --- a/app/views/layouts/jira_connect.html.haml +++ b/app/views/layouts/jira_connect.html.haml @@ -5,7 +5,7 @@ GitLab = yield :page_specific_styles - = javascript_include_tag 'https://connect-cdn.atl-paas.net/all.js' + = javascript_include_tag Gitlab.config.jira_connect.atlassian_js_url = Gon::Base.render_data(nonce: content_security_policy_nonce) = yield :head %body diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index 8815dec5a6b..717175e8eb3 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -14,7 +14,7 @@ %span.nav-item-name = _('Overview') %ul.sidebar-sub-level-items - = nav_link(controller: %w[dashboard admin admin/projects users groups jobs runners gitaly_servers cohorts], html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: %w[dashboard admin admin/projects users groups jobs runners gitaly_servers cohorts], html_options: { class: "fly-out-top-item" }) do = link_to admin_root_path do %strong.fly-out-top-item-name = _('Overview') @@ -82,7 +82,7 @@ = _('Monitoring') %ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_monitoring_submenu_content' } } - = nav_link(controller: admin_monitoring_nav_links, html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: admin_monitoring_nav_links, html_options: { class: "fly-out-top-item" }) do = link_to admin_system_info_path do %strong.fly-out-top-item-name = _('Monitoring') @@ -117,7 +117,7 @@ %span.nav-item-name = _('Messages') %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :broadcast_messages, html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: :broadcast_messages, html_options: { class: "fly-out-top-item" }) do = link_to admin_broadcast_messages_path do %strong.fly-out-top-item-name = _('Messages') @@ -129,7 +129,7 @@ %span.nav-item-name = _('System Hooks') %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: [:hooks, :hook_logs], html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: [:hooks, :hook_logs], html_options: { class: "fly-out-top-item" }) do = link_to admin_hooks_path do %strong.fly-out-top-item-name = _('System Hooks') @@ -141,7 +141,7 @@ %span.nav-item-name = _('Applications') %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :applications, html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: :applications, html_options: { class: "fly-out-top-item" }) do = link_to admin_applications_path do %strong.fly-out-top-item-name = _('Applications') @@ -154,7 +154,7 @@ = _('Abuse Reports') = gl_badge_tag number_with_delimiter(AbuseReport.count(:all)), variant: :info, size: :sm %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :abuse_reports, html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: :abuse_reports, html_options: { class: "fly-out-top-item" }) do = link_to admin_abuse_reports_path do %strong.fly-out-top-item-name = _('Abuse Reports') @@ -170,7 +170,7 @@ %span.nav-item-name = _('Kubernetes') %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :clusters, html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: :clusters, html_options: { class: "fly-out-top-item" }) do = link_to admin_clusters_path do %strong.fly-out-top-item-name = _('Kubernetes') @@ -183,7 +183,7 @@ %span.nav-item-name = _('Spam Logs') %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :spam_logs, html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: :spam_logs, html_options: { class: "fly-out-top-item" }) do = link_to admin_spam_logs_path do %strong.fly-out-top-item-name = _('Spam Logs') @@ -199,7 +199,7 @@ %span.nav-item-name = _('Deploy Keys') %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :deploy_keys, html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: :deploy_keys, html_options: { class: "fly-out-top-item" }) do = link_to admin_deploy_keys_path do %strong.fly-out-top-item-name = _('Deploy Keys') @@ -213,7 +213,7 @@ %span.nav-item-name = _('Labels') %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :labels, html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: :labels, html_options: { class: "fly-out-top-item" }) do = link_to admin_labels_path do %strong.fly-out-top-item-name = _('Labels') @@ -227,7 +227,7 @@ %ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_settings_submenu_content' } } -# This active_nav_link check is also used in `app/views/layouts/admin.html.haml` - = nav_link(controller: [:application_settings, :integrations, :appearances], html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: [:application_settings, :integrations, :appearances], html_options: { class: "fly-out-top-item" }) do = link_to general_admin_application_settings_path do %strong.fly-out-top-item-name = _('Settings') @@ -273,7 +273,7 @@ = link_to network_admin_application_settings_path, title: _('Network'), data: { qa_selector: 'admin_settings_network_link' } do %span = _('Network') - = nav_link(controller: :appearances ) do + = nav_link(controller: :appearances) do = link_to admin_application_settings_appearances_path do %span = _('Appearance') diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml index 0e3327935ca..e1978009114 100644 --- a/app/views/layouts/nav/sidebar/_profile.html.haml +++ b/app/views/layouts/nav/sidebar/_profile.html.haml @@ -12,7 +12,7 @@ %span.nav-item-name = _('Profile') %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(path: 'profiles#show', html_options: { class: "fly-out-top-item" } ) do + = nav_link(path: 'profiles#show', html_options: { class: "fly-out-top-item" }) do = link_to profile_path do %strong.fly-out-top-item-name = _('Profile') @@ -23,7 +23,7 @@ %span.nav-item-name = _('Account') %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: [:accounts, :two_factor_auths], html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: [:accounts, :two_factor_auths], html_options: { class: "fly-out-top-item" }) do = link_to profile_account_path do %strong.fly-out-top-item-name = _('Account') @@ -36,7 +36,7 @@ %span.nav-item-name = _('Applications') %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: 'oauth/applications', html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: 'oauth/applications', html_options: { class: "fly-out-top-item" }) do = link_to applications_profile_path do %strong.fly-out-top-item-name = _('Applications') @@ -47,7 +47,7 @@ %span.nav-item-name = _('Chat') %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :chat_names, html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: :chat_names, html_options: { class: "fly-out-top-item" }) do = link_to profile_chat_names_path do %strong.fly-out-top-item-name = _('Chat') @@ -59,7 +59,7 @@ %span.nav-item-name = _('Access Tokens') %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :personal_access_tokens, html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: :personal_access_tokens, html_options: { class: "fly-out-top-item" }) do = link_to profile_personal_access_tokens_path do %strong.fly-out-top-item-name = _('Access Tokens') @@ -70,7 +70,7 @@ %span.nav-item-name = _('Emails') %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :emails, html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: :emails, html_options: { class: "fly-out-top-item" }) do = link_to profile_emails_path do %strong.fly-out-top-item-name = _('Emails') @@ -82,7 +82,7 @@ %span.nav-item-name = _('Password') %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :passwords, html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: :passwords, html_options: { class: "fly-out-top-item" }) do = link_to edit_profile_password_path do %strong.fly-out-top-item-name = _('Password') @@ -93,7 +93,7 @@ %span.nav-item-name = _('Notifications') %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :notifications, html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: :notifications, html_options: { class: "fly-out-top-item" }) do = link_to profile_notifications_path do %strong.fly-out-top-item-name = _('Notifications') @@ -104,7 +104,7 @@ %span.nav-item-name = _('SSH Keys') %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :keys, html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: :keys, html_options: { class: "fly-out-top-item" }) do = link_to profile_keys_path do %strong.fly-out-top-item-name = _('SSH Keys') @@ -115,7 +115,7 @@ %span.nav-item-name = _('GPG Keys') %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :gpg_keys, html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: :gpg_keys, html_options: { class: "fly-out-top-item" }) do = link_to profile_gpg_keys_path do %strong.fly-out-top-item-name = _('GPG Keys') @@ -126,7 +126,7 @@ %span.nav-item-name = _('Preferences') %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :preferences, html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: :preferences, html_options: { class: "fly-out-top-item" }) do = link_to profile_preferences_path do %strong.fly-out-top-item-name = _('Preferences') @@ -137,7 +137,7 @@ %span.nav-item-name = _('Active Sessions') %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :active_sessions, html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: :active_sessions, html_options: { class: "fly-out-top-item" }) do = link_to profile_active_sessions_path do %strong.fly-out-top-item-name = _('Active Sessions') @@ -148,7 +148,7 @@ %span.nav-item-name = _('Authentication log') %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(path: 'profiles#audit_log', html_options: { class: "fly-out-top-item" } ) do + = nav_link(path: 'profiles#audit_log', html_options: { class: "fly-out-top-item" }) do = link_to audit_log_profile_path do %strong.fly-out-top-item-name = _('Authentication Log') diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index a06f9f8d6ef..67c3cd9cc54 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -1 +1 @@ -= render partial: 'shared/nav/sidebar', object: Sidebars::Projects::Panel.new(project_sidebar_context(@project, current_user, current_ref)) += render partial: 'shared/nav/sidebar', object: Sidebars::Projects::Panel.new(project_sidebar_context(@project, current_user, current_ref, ref_type: @ref_type)) diff --git a/app/views/layouts/project_settings.html.haml b/app/views/layouts/project_settings.html.haml index 97d9f2fbc78..29e30c4434f 100644 --- a/app/views/layouts/project_settings.html.haml +++ b/app/views/layouts/project_settings.html.haml @@ -1,5 +1,6 @@ - page_title _("Settings") - nav "project" +- add_page_specific_style 'page_bundles/settings' - enable_search_settings locals: { container_class: 'gl-my-5' } diff --git a/app/views/layouts/search.html.haml b/app/views/layouts/search.html.haml index dd4b9e45207..44c4b14e90d 100644 --- a/app/views/layouts/search.html.haml +++ b/app/views/layouts/search.html.haml @@ -1,4 +1,5 @@ - page_title _("Search") - header_title _("Search"), search_path +- add_page_specific_style 'page_bundles/search' = render template: "layouts/application" diff --git a/app/views/notify/_reassigned_issuable_email.html.haml b/app/views/notify/_reassigned_issuable_email.html.haml index 54e51e07c86..ead8e5d0a7e 100644 --- a/app/views/notify/_reassigned_issuable_email.html.haml +++ b/app/views/notify/_reassigned_issuable_email.html.haml @@ -1,4 +1,4 @@ -- to_names = content_tag(:strong, issuable.assignees.any? ? sanitize_name(issuable.assignee_list) : s_('Unassigned')) +- to_names = content_tag(:strong, issuable.assignees.any? ? sanitize_name(issuable.assignee_list) : _('Unassigned')) %p - if previous_assignees.any? diff --git a/app/views/notify/access_token_revoked_email.html.haml b/app/views/notify/access_token_revoked_email.html.haml index 4d9b9e14d14..ecd2b3e84b2 100644 --- a/app/views/notify/access_token_revoked_email.html.haml +++ b/app/views/notify/access_token_revoked_email.html.haml @@ -2,6 +2,8 @@ = _('Hi %{username}!') % { username: sanitize_name(@user.name) } %p = html_escape(_('A personal access token, named %{code_start}%{token_name}%{code_end}, has been revoked.')) % { code_start: '<code>'.html_safe, token_name: @token_name, code_end: '</code>'.html_safe } +- if @source == 'secret_detection' + = _('We found your token in a public project and have automatically revoked it to protect your account.') %p - pat_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url } = html_escape(_('You can check your tokens or create a new one in your %{pat_link_start}personal access tokens settings%{pat_link_end}.')) % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe } diff --git a/app/views/notify/access_token_revoked_email.text.erb b/app/views/notify/access_token_revoked_email.text.erb index 17dd628d76c..a0623f96488 100644 --- a/app/views/notify/access_token_revoked_email.text.erb +++ b/app/views/notify/access_token_revoked_email.text.erb @@ -1,5 +1,9 @@ <%= _('Hi %{username}!') % { username: sanitize_name(@user.name) } %> <%= _('A personal access token, named %{token_name}, has been revoked.') % { token_name: @token_name } %> +<% if @source == 'secret_detection' %> + +<%= _('We found your token in a public project and have automatically revoked it to protect your account.') %> +<% end %> <%= _('You can check your tokens or create a new one in your personal access tokens settings %{pat_link}.') % { pat_link: @target_url } %> diff --git a/app/views/notify/autodevops_disabled_email.html.haml b/app/views/notify/autodevops_disabled_email.html.haml index bdf2a1136d3..d6812821966 100644 --- a/app/views/notify/autodevops_disabled_email.html.haml +++ b/app/views/notify/autodevops_disabled_email.html.haml @@ -11,7 +11,7 @@ - link_style = "color: #1b69b6; text-decoration:none;" - pipeline_link = link_to("\##{@pipeline.iid}", pipeline_url(@pipeline), style: link_style).html_safe - project_link = link_to(@project.name, project_url(@project), style: link_style).html_safe - - supported_langs_link = link_to(s_('Notify|currently supported languages'), 'https://docs.gitlab.com/ee/topics/autodevops/#currently-supported-languages', style: link_style ).html_safe + - supported_langs_link = link_to(s_('Notify|currently supported languages'), 'https://docs.gitlab.com/ee/topics/autodevops/#currently-supported-languages', style: link_style).html_safe - settings_link = link_to(s_('Notify|CI/CD project settings'), project_settings_ci_cd_url(@project), style: link_style).html_safe = s_('Notify|The Auto DevOps pipeline failed for pipeline %{pipeline_link} and has been disabled for %{project_link}. In order to use the Auto DevOps pipeline with your project, please review the %{supported_langs_link}, adjust your project accordingly, and turn on the Auto DevOps pipeline within your %{settings_link}.').html_safe % { pipeline_link: pipeline_link, project_link: project_link, supported_langs_link: supported_langs_link, settings_link: settings_link } diff --git a/app/views/notify/issue_moved_email.html.haml b/app/views/notify/issue_moved_email.html.haml index c77a863d1a4..666aa45540e 100644 --- a/app/views/notify/issue_moved_email.html.haml +++ b/app/views/notify/issue_moved_email.html.haml @@ -2,6 +2,6 @@ = s_('Notify|Issue was moved to another project.') - if @can_access_project %p - = sprintf(s_('Notify|New issue: %{project_issue_url}'), { project_issue_url: link_to(@new_issue.title, project_issue_url(@new_project, @new_issue)) } ).html_safe + = sprintf(s_('Notify|New issue: %{project_issue_url}'), { project_issue_url: link_to(@new_issue.title, project_issue_url(@new_project, @new_issue)) }).html_safe - else = s_("Notify|You don't have access to the project.") diff --git a/app/views/notify/repository_push_email.html.haml b/app/views/notify/repository_push_email.html.haml index ee219914513..d493f9d5d98 100644 --- a/app/views/notify/repository_push_email.html.haml +++ b/app/views/notify/repository_push_email.html.haml @@ -60,7 +60,7 @@ - if diff_file.deleted_file? %strong< = diff_file.old_path - = s_('deleted') + = _('deleted') - elsif diff_file.renamed_file? %strong< = diff_file.old_path diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index cdd5a9ae7a1..bc0d615bb64 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -24,10 +24,12 @@ %p #{_('Status')}: #{current_user.two_factor_enabled? ? _('Enabled') : _('Disabled')} - if current_user.two_factor_enabled? - = link_to _('Manage two-factor authentication'), profile_two_factor_auth_path, class: 'gl-button btn btn-confirm' + = render Pajamas::ButtonComponent.new(variant: :confirm, href: profile_two_factor_auth_path) do + = _('Manage two-factor authentication') - else .gl-mb-3 - = link_to _('Enable two-factor authentication'), profile_two_factor_auth_path, class: 'gl-button btn btn-confirm', data: { qa_selector: 'enable_2fa_button' } + = render Pajamas::ButtonComponent.new(variant: :confirm, href: profile_two_factor_auth_path, button_options: { data: { qa_selector: 'enable_2fa_button' }}) do + = _('Enable two-factor authentication') .col-lg-12 %hr diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml index e6d91543585..5c4ea7b2ecb 100644 --- a/app/views/profiles/keys/_form.html.haml +++ b/app/views/profiles/keys/_form.html.haml @@ -12,7 +12,11 @@ = f.label :title, s_('Profiles|Title'), class: 'label-bold' = f.text_field :title, class: "form-control gl-form-input input-lg", required: true, placeholder: s_('Profiles|Example: MacBook key'), data: { qa_selector: 'key_title_field' } %p.form-text.text-muted= s_('Profiles|Key titles are publicly visible.') - + .form-row + .col.form-group + = f.label :usage_type, s_('Profiles|Usage type') + .gl-md-form-input-lg + = f.select :usage_type, options_for_select(ssh_key_usage_types, :auth_and_signing), {}, { class: 'gl-form-select custom-select' } .form-row .col.form-group .js-access-tokens-expires-at{ data: {min_date: Date.tomorrow, max_date: max_date, default_date_offset: 365, description: ssh_key_expires_field_description } } diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml index de4a19bdad7..219e7c4d2fe 100644 --- a/app/views/profiles/keys/_key.html.haml +++ b/app/views/profiles/keys/_key.html.haml @@ -25,7 +25,10 @@ %span.expires.gl-mr-3 = key.expired? ? s_('Profiles|Expired:') : s_('Profiles|Expires:') = key.expires_at ? key.expires_at.to_date : _('Never') + %span.last-used-at.gl-mr-3 + = s_('Profiles|Usage type:') + = ssh_key_usage_types.invert[key.usage_type] %span.key-created-at.gl-display-flex.gl-align-items-center - if key.can_delete? .gl-ml-3 - = render 'shared/ssh_keys/key_delete', html_class: "btn gl-button btn-icon btn-default js-confirm-modal-button", button_data: ssh_key_delete_modal_data(key, path_to_key(key, is_admin)) + = render 'shared/ssh_keys/key_delete', icon: true, button_data: ssh_key_delete_modal_data(key, path_to_key(key, is_admin)) diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml index 04fa1d96204..3c05502be57 100644 --- a/app/views/profiles/keys/_key_details.html.haml +++ b/app/views/profiles/keys/_key_details.html.haml @@ -10,6 +10,9 @@ %span.light= _('Title:') %strong= @key.title %li + %span.light= s_('Profiles|Usage type:') + %strong= ssh_key_usage_types.invert[@key.usage_type] + %li %span.light= _('Created on:') %strong= @key.created_at.to_s(:medium) %li @@ -39,4 +42,4 @@ .col-md-12 .float-right - if @key.can_delete? - = render 'shared/ssh_keys/key_delete', text: _('Delete'), html_class: "btn btn-danger gl-button delete-key js-confirm-modal-button", button_data: ssh_key_delete_modal_data(@key, path_to_key(@key, is_admin)) + = render 'shared/ssh_keys/key_delete', button_data: ssh_key_delete_modal_data(@key, path_to_key(@key, is_admin)) diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index a1d6ef3fec5..24ef9cf4dec 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -73,7 +73,7 @@ .form-group = f.label :layout, class: 'label-bold' do = s_('Preferences|Layout width') - = f.select :layout, layout_choices, {}, class: 'select2' + = f.select :layout, layout_choices, {}, class: 'gl-form-select custom-select' .form-text.text-muted = s_('Preferences|Choose between fixed (max. 1280px) and fluid (%{percentage}) application layout.').html_safe % { percentage: '100%' } .form-group @@ -88,7 +88,7 @@ .form-group = f.label :project_view, class: 'label-bold' do = s_('Preferences|Project overview content') - = f.select :project_view, project_view_choices, {}, class: 'select2' + = f.select :project_view, project_view_choices, {}, class: 'gl-form-select custom-select' .form-text.text-muted = s_('Preferences|Choose what content you want to see on a project’s overview page.') .form-group @@ -103,7 +103,7 @@ - supported_characters = %w(" ' ` ( [ { < * _).map { |char| "<code>#{char}</code>" }.join(', ') = f.gitlab_ui_checkbox_component :markdown_surround_selection, s_('Preferences|Surround text selection when typing quotes or brackets'), - help_text: sprintf(s_( "Preferences|When you type in a description or comment box, selected text is surrounded by the corresponding character after typing one of the following characters: %{supported_characters}."), { supported_characters: supported_characters }).html_safe + help_text: sprintf(s_("Preferences|When you type in a description or comment box, selected text is surrounded by the corresponding character after typing one of the following characters: %{supported_characters}."), { supported_characters: supported_characters }).html_safe .form-group = f.gitlab_ui_checkbox_component :markdown_automatic_lists, s_('Preferences|Automatically add new list items'), @@ -144,9 +144,24 @@ .form-group = f.label :first_day_of_week, class: 'label-bold' do = _('First day of the week') - = f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'select2' + = f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'gl-form-select custom-select' .col-sm-12 %hr + - if Feature.enabled?(:vscode_web_ide, current_user) + .row.js-preferences-form.js-search-settings-section + .col-lg-4.profile-settings-sidebar#web-ide + %h4.gl-mt-0 + = s_('Preferences|Web IDE') + %p + = s_('Preferences|The Web IDE Beta is the default Web IDE experience.') + = link_to _('Learn more'), help_page_path('user/project/web_ide_beta/index.md'), target: '_blank', rel: 'noopener noreferrer' + .col-lg-8 + .form-group + = f.gitlab_ui_checkbox_component :use_legacy_web_ide, + s_('Preferences|Opt out of the Web IDE Beta'), + help_text: s_('Preferences|The Web IDE remains available alongside the Beta.') + .col-sm-12 + %hr .row.js-preferences-form.js-search-settings-section .col-lg-4.profile-settings-sidebar#time-preferences %h4.gl-mt-0 diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 51222784847..712d6fabf82 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -1,20 +1,23 @@ +- show_auto_devops_callout = show_auto_devops_callout?(@project) - is_project_overview = local_assigns.fetch(:is_project_overview, false) - ref = local_assigns.fetch(:ref) { current_ref } - 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 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.js-per-page{ data: { blame_per_page: Projects::BlameService::PER_PAGE } } - .nav-block.gl-display-flex.gl-xs-flex-direction-column.gl-align-items-stretch - = render 'projects/tree/tree_header', tree: @tree, is_project_overview: is_project_overview - .info-well.gl-display-none.gl-sm-display-flex.project-last-commit.gl-flex-direction-column #js-last-commit.gl-m-auto = gl_loading_icon(size: 'md') #js-code-owners + .nav-block.gl-display-flex.gl-xs-flex-direction-column.gl-align-items-stretch + = render 'projects/tree/tree_header', tree: @tree, is_project_overview: is_project_overview + + - if project.forked? && Feature.enabled?(:fork_divergence_counts, @project.fork_source) + = render 'projects/fork_info' + - if is_project_overview .project-buttons.gl-mb-5.js-show-on-project-root{ data: { qa_selector: 'project_buttons' } } = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout), project_buttons: true diff --git a/app/views/projects/_flash_messages.html.haml b/app/views/projects/_flash_messages.html.haml index 7395495b537..2d9f7e49ddc 100644 --- a/app/views/projects/_flash_messages.html.haml +++ b/app/views/projects/_flash_messages.html.haml @@ -2,11 +2,13 @@ = content_for :flash_message do = render partial: 'deletion_failed', locals: { project: project } - - if current_user && can?(current_user, :download_code, project) + - if current_user && can?(current_user, :read_code, project) = render 'shared/no_ssh' = render 'shared/no_password' - unless project.empty_repo? = render 'shared/auto_devops_implicitly_enabled_banner', project: project + - if show_auto_devops_callout?(@project) + = render 'shared/auto_devops_callout' = render_if_exists 'projects/above_size_limit_warning', project: project = render_if_exists 'shared/shared_runners_minutes_limit', project: project, classes: [container_class, ("limit-container-width" unless fluid_layout)] = render_if_exists 'projects/terraform_banner', project: project diff --git a/app/views/projects/_fork_info.html.haml b/app/views/projects/_fork_info.html.haml new file mode 100644 index 00000000000..7fe30214e97 --- /dev/null +++ b/app/views/projects/_fork_info.html.haml @@ -0,0 +1,14 @@ +.info-well.gl-sm-display-flex.gl-flex-direction-column + .well-segment.gl-p-5.gl-w-full.gl-display-flex + .gl-icon.s32.gl-mt-4.gl-mr-4.gl-text-center + = sprite_icon('fork') + - source = visible_fork_source(@project) + - if source + %div + #{ s_('ForkedFromProjectPath|Forked from') } + = link_to source.full_name, project_path(source), data: { qa_selector: 'forked_from_link' } + .gl-text-secondary + = fork_divergence_message(::Projects::Forks::DivergenceCounts.new(@project, @ref).counts) + - else + .gl-py-4 + = s_('ForkedFromProjectPath|Forked from an inaccessible project') diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index a862b841008..dc426f2f6b7 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -4,17 +4,16 @@ - cache_enabled = Feature.enabled?(:cache_home_panel, @project, type: :development) .project-home-panel.js-show-on-project-root.gl-my-5{ class: [("empty-project" if empty_repo)] } - .gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-sm-flex-direction-column.gl-mb-3 - .home-panel-title-row.gl-display-flex + .gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-sm-flex-direction-column.gl-mb-3.gl-gap-5 + .home-panel-title-row.gl-display-flex.gl-align-items-center %div{ class: 'avatar-container rect-avatar s64 home-panel-avatar gl-flex-shrink-0 gl-w-11 gl-h-11 gl-mr-3! float-none' } = project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s64', width: 64, height: 64, itemprop: 'image') - .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.gl-font-size-h1{ data: { qa_selector: 'project_name_content' }, itemprop: 'name' } - = @project.name - %span.visibility-icon.gl-text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) } - = visibility_level_icon(@project.visibility_level, options: { class: 'icon' }) - = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project + %div + %h1.home-panel-title.gl-font-size-h1.gl-mt-3.gl-mb-2.gl-display-flex{ data: { qa_selector: 'project_name_content' }, itemprop: 'name' } + = @project.name + %span.visibility-icon.gl-text-secondary.has-tooltip.gl-ml-2{ data: { container: 'body' }, title: visibility_icon_description(@project) } + = visibility_level_icon(@project.visibility_level, options: { class: 'icon' }) + = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project, additional_classes: 'gl-align-self-center gl-ml-2' .home-panel-metadata.gl-font-sm.gl-text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ data: { qa_selector: 'project_id_content' }, itemprop: 'identifier' } - if can?(current_user, :read_project, @project) %span.gl-display-inline-block.gl-vertical-align-middle @@ -25,22 +24,20 @@ = render 'shared/members/access_request_links', source: @project = cache_if(cache_enabled, [@project, @project.star_count, @project.forks_count, :buttons, current_user, @notification_setting], expires_in: 1.day) do - .project-repo-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-start.gl-flex-wrap.gl-mt-5 + .project-repo-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-center.gl-flex-wrap.gl-gap-3 - if current_user - if current_user.admin? - = link_to [:admin, @project], class: 'btn gl-button btn-icon gl-align-self-start gl-py-2! gl-mr-3', title: _('View project in admin area'), + = link_to [:admin, @project], class: 'btn btn-default gl-button btn-icon', title: _('View project in admin area'), data: {toggle: 'tooltip', placement: 'top', container: 'body'} do = sprite_icon('admin') - .gl-display-flex.gl-align-items-start.gl-mr-3 - - if @notification_setting - .js-vue-notification-dropdown{ data: { button_size: "small", disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), project_id: @project.id, no_flip: 'true' } } + - if @notification_setting + .js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), project_id: @project.id, no_flip: 'true' } } - .count-buttons.gl-display-flex.gl-align-items-flex-start - = render 'projects/buttons/star' - = render 'projects/buttons/fork' + = render 'projects/buttons/star' + = render 'projects/buttons/fork' - - if can?(current_user, :download_code, @project) - = cache_if(cache_enabled, [@project, :download_code], expires_in: 1.minute) do + - if can?(current_user, :read_code, @project) + = cache_if(cache_enabled, [@project, :read_code], expires_in: 1.minute) do %nav.project-stats - if @project.empty_repo? = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors @@ -56,7 +53,7 @@ %button.btn.gl-button.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" } = _("Read more") - - if @project.forked? + - if @project.forked? && Feature.disabled?(:fork_divergence_counts, @project.fork_source) %p - source = visible_fork_source(@project) - if source diff --git a/app/views/projects/_invite_groups_modal.html.haml b/app/views/projects/_invite_groups_modal.html.haml index 40dc0009b24..101acd9149e 100644 --- a/app/views/projects/_invite_groups_modal.html.haml +++ b/app/views/projects/_invite_groups_modal.html.haml @@ -1,3 +1,3 @@ - return unless can_invite_members_for_project?(project) -.js-invite-groups-modal{ data: common_invite_group_modal_data(project, ProjectMember, 'true') } +.js-invite-groups-modal{ data: { reload_page_on_submit: local_assigns.fetch(:reload_page_on_submit, false).to_s }.merge(common_invite_group_modal_data(project, ProjectMember, 'true')) } diff --git a/app/views/projects/_invite_members_modal.html.haml b/app/views/projects/_invite_members_modal.html.haml index 16288f4357a..53f74a0f270 100644 --- a/app/views/projects/_invite_members_modal.html.haml +++ b/app/views/projects/_invite_members_modal.html.haml @@ -2,4 +2,5 @@ .js-invite-members-modal{ data: { is_project: 'true', access_levels: ProjectMember.permissible_access_level_roles(current_user, project).to_json, + reload_page_on_submit: local_assigns.fetch(:reload_page_on_submit, false).to_s, help_link: help_page_url('user/permissions') }.merge(common_invite_modal_dataset(project)).merge(users_filter_data(project.group)) } diff --git a/app/views/projects/_merge_request_merge_checks_settings.html.haml b/app/views/projects/_merge_request_merge_checks_settings.html.haml index 8c12399fdbb..bb7a7731067 100644 --- a/app/views/projects/_merge_request_merge_checks_settings.html.haml +++ b/app/views/projects/_merge_request_merge_checks_settings.html.haml @@ -3,17 +3,6 @@ .form-group %b= s_('ProjectSettings|Merge checks') %p.text-secondary= s_('ProjectSettings|These checks must pass before merge requests can be merged.') - .builds-feature - = form.gitlab_ui_checkbox_component :only_allow_merge_if_pipeline_succeeds, - s_('ProjectSettings|Pipelines must succeed'), - help_text: s_("ProjectSettings|Merge requests can't be merged if the latest pipeline did not succeed or is still running.") - .gl-pl-6 - = form.gitlab_ui_checkbox_component :allow_merge_on_skipped_pipeline, - s_('ProjectSettings|Skipped pipelines are considered successful'), - help_text: s_('ProjectSettings|Introduces the risk of merging changes that do not pass the pipeline.'), - checkbox_options: { class: 'gl-pl-6' } + = render 'projects/merge_request_pipelines_and_threads_options', form: form, project: @project = render_if_exists 'projects/merge_request_merge_checks_status_checks', form: form, project: @project - = form.gitlab_ui_checkbox_component :only_allow_merge_if_all_discussions_are_resolved, - s_('ProjectSettings|All threads must be resolved'), - checkbox_options: { data: { qa_selector: 'allow_merge_if_all_discussions_are_resolved_checkbox' } } = render_if_exists 'projects/merge_request_merge_checks_jira_enforcement', form: form, project: @project diff --git a/app/views/projects/_merge_request_pipelines_and_threads_options.html.haml b/app/views/projects/_merge_request_pipelines_and_threads_options.html.haml new file mode 100644 index 00000000000..94f8d3cc4a3 --- /dev/null +++ b/app/views/projects/_merge_request_pipelines_and_threads_options.html.haml @@ -0,0 +1,13 @@ +- form = local_assigns.fetch(:form) + += form.gitlab_ui_checkbox_component :only_allow_merge_if_pipeline_succeeds, + s_('ProjectSettings|Pipelines must succeed'), + help_text: s_("ProjectSettings|Merge requests can't be merged if the latest pipeline did not succeed or is still running.") +.gl-pl-6 + = form.gitlab_ui_checkbox_component :allow_merge_on_skipped_pipeline, + s_('ProjectSettings|Skipped pipelines are considered successful'), + help_text: s_('ProjectSettings|Introduces the risk of merging changes that do not pass the pipeline.'), + checkbox_options: { class: 'gl-pl-6' } += form.gitlab_ui_checkbox_component :only_allow_merge_if_all_discussions_are_resolved, + s_('ProjectSettings|All threads must be resolved'), + checkbox_options: { data: { qa_selector: 'allow_merge_if_all_discussions_are_resolved_checkbox' } } diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml index 0699e39b420..ec83782985b 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -10,6 +10,7 @@ = f.label :name, class: 'label-bold' do %span= _("Project name") = f.text_field :name, placeholder: "My awesome project", class: "form-control gl-form-input input-lg", data: { qa_selector: 'project_name', track_label: "#{track_label}", track_action: "activate_form_input", track_property: "project_name", track_value: "" }, required: true, aria: { required: true } + #project_name_error.gl-field-error.hidden .form-group.project-path.col-sm-6.gl-pr-0 = f.label :namespace_id, class: 'label-bold' do %span= _('Project URL') @@ -18,6 +19,8 @@ - namespace_id = namespace_id_from(params) .js-vue-new-project-url-select{ data: { namespace_full_path: GroupFinder.new(current_user).execute(id: namespace_id)&.full_path || @current_user_group&.full_path, namespace_id: namespace_id || @current_user_group&.id, + input_id: 'project_namespace_id', + input_name: 'project[namespace_id]', root_url: root_url, track_label: track_label, user_namespace_id: current_user.namespace.id } } diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index a907e175443..87a6b54d697 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -28,7 +28,8 @@ .file-buttons.gl-display-flex.gl-align-items-center.gl-justify-content-end - if is_markdown - = render 'shared/blob/markdown_buttons', show_fullscreen_button: false, supports_file_upload: false + - unless Feature.enabled?(:source_editor_toolbar, current_user) + = render 'shared/blob/markdown_buttons', show_fullscreen_button: false, supports_file_upload: false %span.soft-wrap-toggle = render Pajamas::ButtonComponent.new(icon: 'soft-unwrap', button_options: { class: 'no-wrap' }) do = _("No wrap") diff --git a/app/views/projects/blob/_template_selectors.html.haml b/app/views/projects/blob/_template_selectors.html.haml index 249c474587c..4fe68c1ce1a 100644 --- a/app/views/projects/blob/_template_selectors.html.haml +++ b/app/views/projects/blob/_template_selectors.html.haml @@ -4,12 +4,12 @@ - toggle_text = should_suggest_gitlab_ci_yml? ? '.gitlab-ci.yml' : 'Select a template type' = dropdown_tag(_(toggle_text), options: { toggle_class: 'js-template-type-selector', dropdown_class: 'dropdown-menu-selectable', data: { qa_selector: 'template_type_dropdown' } }) .license-selector.js-license-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-license-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: licenses_for_select(@project), project: @project.name, fullname: @project.namespace.human_name, qa_selector: 'license_dropdown' } } ) + = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-license-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: licenses_for_select(@project), project: @project.name, fullname: @project.namespace.human_name, qa_selector: 'license_dropdown' } }) .gitignore-selector.js-gitignore-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitignore-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitignore_names(@project), qa_selector: 'gitignore_dropdown' } } ) + = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitignore-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitignore_names(@project), qa_selector: 'gitignore_dropdown' } }) .metrics-dashboard-selector.js-metrics-dashboard-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-metrics-dashboard-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: metrics_dashboard_ymls(@project), qa_selector: 'metrics_dashboard_dropdown' } } ) + = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-metrics-dashboard-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: metrics_dashboard_ymls(@project), qa_selector: 'metrics_dashboard_dropdown' } }) #gitlab-ci-yml-selector.gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitlab-ci-yml-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls(@project), selected: params[:template], qa_selector: 'gitlab_ci_yml_dropdown' } } ) + = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitlab-ci-yml-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls(@project), selected: params[:template], qa_selector: 'gitlab_ci_yml_dropdown' } }) .dockerfile-selector.js-dockerfile-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-dockerfile-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: dockerfile_names(@project), qa_selector: 'dockerfile_dropdown' } } ) + = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-dockerfile-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: dockerfile_names(@project), qa_selector: 'dockerfile_dropdown' } }) diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml index 63d0cf7145d..91efd5ef048 100644 --- a/app/views/projects/branches/new.html.haml +++ b/app/views/projects/branches/new.html.haml @@ -17,18 +17,12 @@ .form-text.text-muted.text-danger.js-branch-name-error .form-group.row = label_tag :ref, _('Create from'), class: 'col-form-label col-sm-2' - .col-sm-10.create-from - .dropdown - = 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 - = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3") - = render 'shared/ref_dropdown', dropdown_class: 'wide' + .col-sm-auto.create-from + .js-new-branch-ref-selector{ data: { project_id: @project.id, default_branch_name: default_ref, hidden_input_name: 'ref' } } .form-text.text-muted = _('Existing branch name, tag, or commit SHA') .form-actions = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { type: 'submit', class: 'gl-mr-3' }) do = _('Create branch') = link_to _('Cancel'), project_branches_path(@project), class: 'gl-button btn btn-default btn-cancel' --# haml-lint:disable InlineJavaScript -%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe + diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml index 34aecd31c57..a755cb9f5b0 100644 --- a/app/views/projects/buttons/_clone.html.haml +++ b/app/views/projects/buttons/_clone.html.haml @@ -27,28 +27,28 @@ = render_if_exists 'projects/buttons/geo' = render_if_exists 'projects/buttons/kerberos_clone_field' %li.divider.mt-2 - %li.pt-2.gl-new-dropdown-item + %li.pt-2.gl-dropdown-item %label.label-bold{ class: 'gl-px-4!' } = _('Open in your IDE') - if ssh_enabled? - escaped_ssh_url_to_repo = CGI.escape(project.ssh_url_to_repo) %a.dropdown-item.open-with-link{ href: 'vscode://vscode.git/clone?url=' + escaped_ssh_url_to_repo } - .gl-new-dropdown-item-text-wrapper + .gl-dropdown-item-text-wrapper = _('Visual Studio Code (SSH)') - if http_enabled? - escaped_http_url_to_repo = CGI.escape(project.http_url_to_repo) %a.dropdown-item.open-with-link{ href: 'vscode://vscode.git/clone?url=' + escaped_http_url_to_repo } - .gl-new-dropdown-item-text-wrapper + .gl-dropdown-item-text-wrapper = _('Visual Studio Code (HTTPS)') - if ssh_enabled? %a.dropdown-item.open-with-link{ href: 'jetbrains://idea/checkout/git?idea.required.plugins.id=Git4Idea&checkout.repo=' + escaped_ssh_url_to_repo } - .gl-new-dropdown-item-text-wrapper + .gl-dropdown-item-text-wrapper = _('IntelliJ IDEA (SSH)') - if http_enabled? %a.dropdown-item.open-with-link{ href: 'jetbrains://idea/checkout/git?idea.required.plugins.id=Git4Idea&checkout.repo=' + escaped_http_url_to_repo } - .gl-new-dropdown-item-text-wrapper + .gl-dropdown-item-text-wrapper = _('IntelliJ IDEA (HTTPS)') - if show_xcode_link?(@project) %a.dropdown-item.open-with-link{ href: xcode_uri_to_repo(@project) } - .gl-new-dropdown-item-text-wrapper + .gl-dropdown-item-text-wrapper = _("Xcode") diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index 23dcb7f41e1..1fbc399c3ff 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -4,7 +4,7 @@ - if !project.empty_repo? && can?(current_user, :download_code, project) - archive_prefix = "#{project.path}-#{ref.tr('/', '-')}" - .project-action-button.dropdown.gl-new-dropdown.inline> + .project-action-button.dropdown.gl-dropdown.inline> %button.gl-button.btn.btn-default.dropdown-toggle.gl-dropdown-toggle.dropdown-icon-only.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download'), 'data-display' => 'static', data: { qa_selector: 'download_source_code_button' } } = sprite_icon('download', css_class: 'gl-icon dropdown-icon') %span.sr-only= _('Select Archive Format') diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml index 3621853430d..97186149a9d 100644 --- a/app/views/projects/buttons/_fork.html.haml +++ b/app/views/projects/buttons/_fork.html.haml @@ -2,17 +2,17 @@ - if current_user .count-badge.btn-group - if current_user.already_forked?(@project) && current_user.forkable_namespaces.size < 2 - = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: s_('ProjectOverview|Go to your fork'), class: 'gl-button btn btn-default btn-sm has-tooltip fork-btn' do + = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: s_('ProjectOverview|Go to your fork'), class: 'gl-button btn btn-default has-tooltip fork-btn' do = sprite_icon('fork', css_class: 'icon') %span= s_('ProjectOverview|Fork') - else - disabled_tooltip = fork_button_disabled_tooltip(@project) - - count_class = 'disabled' unless can?(current_user, :download_code, @project) + - count_class = 'disabled' unless can?(current_user, :read_code, @project) - button_class = 'disabled' if disabled_tooltip %span.btn-group{ class: ('has-tooltip' if disabled_tooltip), title: disabled_tooltip } - = link_to new_project_fork_path(@project), class: "gl-button btn btn-default btn-sm fork-btn #{button_class}" do + = link_to new_project_fork_path(@project), class: "gl-button btn btn-default fork-btn #{button_class}" do = sprite_icon('fork', css_class: 'icon') %span= s_('ProjectOverview|Fork') - = link_to project_forks_path(@project), title: n_(s_('ProjectOverview|Forks'), s_('ProjectOverview|Forks'), @project.forks_count), class: "gl-button btn btn-default btn-sm count has-tooltip fork-count #{count_class}" do + = link_to project_forks_path(@project), title: n_(s_('ProjectOverview|Forks'), s_('ProjectOverview|Forks'), @project.forks_count), class: "gl-button btn btn-default count has-tooltip fork-count #{count_class}" do = @project.forks_count diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml index eaf906ad89f..d4dcfbdff54 100644 --- a/app/views/projects/buttons/_star.html.haml +++ b/app/views/projects/buttons/_star.html.haml @@ -3,15 +3,15 @@ - icon = starred ? 'star' : 'star-o' - button_text = starred ? s_('ProjectOverview|Unstar') : s_('ProjectOverview|Star') - button_text_classes = starred ? 'starred' : '' - .count-badge.d-inline-flex.align-item-stretch.gl-mr-3.btn-group - = render Pajamas::ButtonComponent.new(size: :small, icon: icon, button_text_classes: button_text_classes, button_options: { class: 'star-btn toggle-star', data: { endpoint: toggle_star_project_path(@project, :json) } }) do + .count-badge.d-inline-flex.align-item-stretch.btn-group + = render Pajamas::ButtonComponent.new(size: :medium, icon: icon, button_text_classes: button_text_classes, button_options: { class: 'star-btn toggle-star', data: { endpoint: toggle_star_project_path(@project, :json) } }) do - button_text - = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'gl-button btn btn-default btn-sm has-tooltip star-count count' do + = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'gl-button btn btn-default has-tooltip star-count count' do = @project.star_count - else - .count-badge.d-inline-flex.align-item-stretch.gl-mr-3.btn-group - = link_to new_user_session_path, class: 'gl-button btn btn-default btn-sm has-tooltip star-btn', title: s_('ProjectOverview|You must sign in to star a project') do + .count-badge.d-inline-flex.align-item-stretch.btn-group + = link_to new_user_session_path, class: 'gl-button btn btn-default has-tooltip star-btn', title: s_('ProjectOverview|You must sign in to star a project') do = sprite_icon('star-o', css_class: 'icon') %span= s_('ProjectOverview|Star') - = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'gl-button btn btn-default btn-sm has-tooltip star-count count' do + = link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'gl-button btn btn-default has-tooltip star-count count' do = @project.star_count diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 6e202063900..079e24c6389 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -53,9 +53,7 @@ = ci_label_for_status(@last_pipeline.status) - if @last_pipeline.stages_count.nonzero? #{ n_(s_('Pipeline|with stage'), s_('Pipeline|with stages'), @last_pipeline.stages_count) } - .mr-widget-pipeline-graph - .stage-cell - .js-commit-pipeline-mini-graph{ data: { stages: @last_pipeline_stages.to_json.html_safe, full_path: @project.full_path, iid: @last_pipeline.iid, graphql_resource_etag: graphql_etag_pipeline_path(@last_pipeline) } } + .js-commit-pipeline-mini-graph{ data: { stages: @last_pipeline_stages.to_json.html_safe, full_path: @project.full_path, iid: @last_pipeline.iid, graphql_resource_etag: graphql_etag_pipeline_path(@last_pipeline) } } - if @last_pipeline.duration in = time_interval_in_words @last_pipeline.duration diff --git a/app/views/projects/commit/_signature.html.haml b/app/views/projects/commit/_signature.html.haml index 978d83bf2b4..c6f1e51049e 100644 --- a/app/views/projects/commit/_signature.html.haml +++ b/app/views/projects/commit/_signature.html.haml @@ -1,3 +1,3 @@ - if signature - - uri = "projects/commit/#{'x509/' if x509_signature?(signature)}" + - uri = "projects/commit/#{'x509/' if signature.x509?}" = render partial: "#{uri}#{signature.verification_status}_signature_badge", locals: { signature: signature } diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml index fb30bfc2953..ad6b524c01b 100644 --- a/app/views/projects/commit/_signature_badge.html.haml +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -17,18 +17,23 @@ - content = capture do - if show_user .clearfix - - uri_signature_badge_user = "projects/commit/#{'x509/' if x509_signature?(signature)}signature_badge_user" + - uri_signature_badge_user = "projects/commit/#{'x509/' if signature.x509?}signature_badge_user" = render partial: "#{uri_signature_badge_user}", locals: { signature: signature } - - if x509_signature?(signature) + - if signature.x509? = render partial: "projects/commit/x509/certificate_details", locals: { signature: signature } - = link_to(_('Learn more about X.509 signed commits'), help_page_path('user/project/repository/x509_signed_commits/index.md'), class: 'gpg-popover-help-link') + = link_to(_('Learn more about X.509 signed commits'), help_page_path('user/project/repository/x509_signed_commits/index.md'), class: 'gl-link gl-display-block') + - elsif ::Feature.enabled?(:ssh_commit_signatures, signature.project) && signature.ssh? + = _('SSH key fingerprint:') + %span.gl-font-monospace= signature.key&.fingerprint_sha256 || _('Unknown') + + = link_to(_('Learn about signing commits with SSH keys.'), help_page_path('user/project/repository/ssh_signed_commits/index.md'), class: 'gl-link gl-display-block') - else = _('GPG Key ID:') - %span.monospace= signature.gpg_key_primary_keyid + %span.gl-font-monospace= signature.gpg_key_primary_keyid - = link_to(_('Learn more about signing commits'), help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link gl-display-block') + = link_to(_('Learn more about signing commits'), help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gl-link gl-display-block') %a{ role: 'button', tabindex: 0, class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } } = label diff --git a/app/views/projects/commit/_signature_badge_user.html.haml b/app/views/projects/commit/_signature_badge_user.html.haml index b20198e76db..656adef6a72 100644 --- a/app/views/projects/commit/_signature_badge_user.html.haml +++ b/app/views/projects/commit/_signature_badge_user.html.haml @@ -1,7 +1,4 @@ -- gpg_key = signature.gpg_key -- user = gpg_key&.user -- user_name = signature.gpg_key_user_name -- user_email = signature.gpg_key_user_email +- user = signature.signed_by_user - if user = link_to user_path(user), class: 'gpg-popover-user-link' do @@ -11,11 +8,14 @@ %div %strong= user.name %div= user.to_reference -- else - = mail_to user_email do - %div - = user_avatar_without_link(user_name: user_name, user_email: user_email, size: 32) +- elsif signature.gpg? # SSH signatures do not have an email embedded in them + - user_name = signature.gpg_key_user_name + - user_email = signature.gpg_key_user_email + - if user_name && user_email + = mail_to user_email do + %div + = user_avatar_without_link(user_name: user_name, user_email: user_email, size: 32) - %div - %strong= user_name - %div= user_email + %div + %strong= user_name + %div= user_email diff --git a/app/views/projects/commit/x509/_signature_badge_user.html.haml b/app/views/projects/commit/x509/_signature_badge_user.html.haml index f3d39b21ec2..da749172369 100644 --- a/app/views/projects/commit/x509/_signature_badge_user.html.haml +++ b/app/views/projects/commit/x509/_signature_badge_user.html.haml @@ -1,5 +1,5 @@ - user_email = signature.x509_certificate.email -- user = signature.user +- user = signature.signed_by_user - if user = link_to user_path(user), class: 'gpg-popover-user-link' do diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml index b5ecc9b0193..b79f17ae7b3 100644 --- a/app/views/projects/commits/_commits.html.haml +++ b/app/views/projects/commits/_commits.html.haml @@ -15,7 +15,7 @@ %li.commits-row{ data: { day: day } } %ul.content-list.commit-list.flex-list - if Feature.enabled?(:cached_commits, project) - = render partial: 'projects/commits/commit', collection: daily_commits, locals: { project: project, ref: ref, merge_request: merge_request }, cached: -> (commit) { commit_partial_cache_key(commit, ref: ref, merge_request: merge_request, request: request) } + = render partial: 'projects/commits/commit', collection: daily_commits, locals: { project: project, ref: ref, merge_request: merge_request }, cached: ->(commit) { commit_partial_cache_key(commit, ref: ref, merge_request: merge_request, request: request) } - else = render partial: 'projects/commits/commit', collection: daily_commits, locals: { project: project, ref: ref, merge_request: merge_request } @@ -29,7 +29,7 @@ %li.commits-row %ul.content-list.commit-list.flex-list - if Feature.enabled?(:cached_commits, project) - = render partial: 'projects/commits/commit', collection: context_commits, locals: { project: project, ref: ref, merge_request: merge_request }, cached: -> (commit) { commit_partial_cache_key(commit, ref: ref, merge_request: merge_request, request: request) } + = render partial: 'projects/commits/commit', collection: context_commits, locals: { project: project, ref: ref, merge_request: merge_request }, cached: ->(commit) { commit_partial_cache_key(commit, ref: ref, merge_request: merge_request, request: request) } - else = render partial: 'projects/commits/commit', collection: context_commits, locals: { project: project, ref: ref, merge_request: merge_request } diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index ae68a13929e..c129d978e7e 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -1,6 +1,7 @@ - breadcrumb_title _("Commits") - add_page_specific_style 'page_bundles/tree' - page_title _("Commits"), @ref + = content_for :meta_tags do = auto_discovery_link_tag(:atom, project_commits_path(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits") @@ -9,7 +10,7 @@ .nav-block .tree-ref-container .tree-ref-holder - = render 'shared/ref_switcher', destination: 'commits' + #js-project-commits-ref-switcher{ data: { "project-id" => @project.id, "ref" => @ref, "commits_path": project_commits_path(@project) } } %ul.breadcrumb.repo-breadcrumb = commits_breadcrumbs @@ -24,7 +25,7 @@ = _("Create merge request") .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 + = form_tag(project_commits_path(@project, @id, ref_type: @ref_type), method: :get, class: 'commits-search-form js-signature-container', data: { 'signatures-path' => namespace_project_signatures_path(ref_type: @ref_type)}) do = 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', spellcheck: false } .control.d-none.d-md-block = link_to project_commits_path(@project, @id, rss_url_options), title: _("Commits feed"), class: 'btn gl-button btn-default btn-icon' do diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index 11984a9d6f6..8ff6d348d95 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -34,7 +34,7 @@ - if load_diff_files_async - url = url_for(safe_params.merge(action: 'diff_files')) .js-diffs-batch{ data: { diff_files_path: url } } - = gl_loading_icon( size: "md", css_class: "gl-mt-4" ) + = gl_loading_icon(size: "md", css_class: "gl-mt-4") - else = render partial: 'projects/diffs/file', collection: diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment, diff_page_context: diff_page_context } diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index 6d60ef92d86..53b2af88511 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -8,28 +8,31 @@ #environments-detail-view{ data: { details: environments_detail_data_json(current_user, @project, @environment) } } #environments-detail-view-header - .environments-container - - if @deployments.blank? - .empty-state - .text-content - %h4.state-title - = _("You don't have any deployments right now.") - %p - = html_escape(_("Define environments in the deploy stage(s) in %{code_open}.gitlab-ci.yml%{code_close} to track deployments here.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - .text-center - = link_to _("Read more"), help_page_path("ci/environments/index.md"), class: "gl-button btn btn-confirm" - - else - .table-holder.gl-overflow-visible - .ci-table.environments{ role: 'grid' } - .gl-responsive-table-row.table-row-header{ role: 'row' } - .table-section.section-15{ role: 'columnheader' }= _('Status') - .table-section.section-10{ role: 'columnheader' }= _('ID') - .table-section.section-10{ role: 'columnheader' }= _('Triggerer') - .table-section.section-25{ role: 'columnheader' }= _('Commit') - .table-section.section-10{ role: 'columnheader' }= _('Job') - .table-section.section-10{ role: 'columnheader' }= _('Created') - .table-section.section-10{ role: 'columnheader' }= _('Deployed') + - if Feature.enabled?(:environment_details_vue, @project) + #environment_details_page + - else + .environments-container + - if @deployments.blank? + .empty-state + .text-content + %h4.state-title + = _("You don't have any deployments right now.") + %p + = html_escape(_("Define environments in the deploy stage(s) in %{code_open}.gitlab-ci.yml%{code_close} to track deployments here.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + .text-center + = link_to _("Read more"), help_page_path("ci/environments/index.md"), class: "gl-button btn btn-confirm" + - else + .table-holder.gl-overflow-visible + .ci-table.environments{ role: 'grid' } + .gl-responsive-table-row.table-row-header{ role: 'row' } + .table-section.section-15{ role: 'columnheader' }= _('Status') + .table-section.section-10{ role: 'columnheader' }= _('ID') + .table-section.section-10{ role: 'columnheader' }= _('Triggerer') + .table-section.section-25{ role: 'columnheader' }= _('Commit') + .table-section.section-10{ role: 'columnheader' }= _('Job') + .table-section.section-10{ role: 'columnheader' }= _('Created') + .table-section.section-10{ role: 'columnheader' }= _('Deployed') - = render @deployments + = render @deployments - = paginate @deployments, theme: 'gitlab' + = paginate @deployments, theme: 'gitlab' diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml index c7639eec75d..a27f076d5dd 100644 --- a/app/views/projects/graphs/show.html.haml +++ b/app/views/projects/graphs/show.html.haml @@ -1,8 +1,14 @@ - page_title _('Contributors') +- if Feature.enabled?(:use_ref_type_parameter, @project) + - graph_path = project_graph_path(@project, current_ref, ref_type: @ref_type, format: :json) + - commits_path = project_commits_path(@project, current_ref, ref_type: @ref_type) +- else + - graph_path = project_graph_path(@project, current_ref, format: :json) + - commits_path = project_commits_path(@project, current_ref) .sub-header-block.gl-bg-gray-10.gl-p-5 .tree-ref-holder.gl-display-inline-block.gl-vertical-align-middle.gl-mr-3> = render 'shared/ref_switcher', destination: 'graphs' - = link_to s_('Commits|History'), project_commits_path(@project, current_ref), class: 'btn gl-button btn-default' + = link_to s_('Commits|History'), commits_path, class: 'btn gl-button btn-default' -.js-contributors-graph{ class: container_class, data: { project_graph_path: project_graph_path(@project, current_ref, format: :json), project_branch: current_ref, default_branch: @project.default_branch } } +.js-contributors-graph{ class: container_class, data: { project_graph_path: graph_path, project_branch: current_ref, default_branch: @project.default_branch } } diff --git a/app/views/projects/issuable/_show.html.haml b/app/views/projects/issuable/_show.html.haml index 0898e0ae52d..ec233bc9aff 100644 --- a/app/views/projects/issuable/_show.html.haml +++ b/app/views/projects/issuable/_show.html.haml @@ -3,6 +3,7 @@ - page_card_attributes issuable.card_attributes - if issuable.relocation_target - page_canonical_link issuable.relocation_target.present(current_user: current_user).web_url +- add_page_specific_style 'page_bundles/issuable' = render "projects/issues/service_desk/alert_moved_from_service_desk", issue: issuable diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index b730eb5072e..f8f57934303 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -1,13 +1,14 @@ - page_title _('Issues') +- add_page_specific_style 'page_bundles/issuable_list' - add_page_specific_style 'page_bundles/issues_list' = content_for :meta_tags do = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@project.name} issues") -.js-jira-issues-import-status{ data: { can_edit: can?(current_user, :admin_project, @project).to_s, +.js-jira-issues-import-status-root{ data: { can_edit: can?(current_user, :admin_project, @project).to_s, is_jira_configured: @project.jira_integration.present?.to_s, issues_path: project_issues_path(@project), project_path: @project.full_path } } -.js-issues-list{ data: project_issues_list_data(@project, current_user) } +.js-issues-list-root{ data: project_issues_list_data(@project, current_user) } - if can?(current_user, :admin_issue, @project) = render 'shared/issuable/bulk_update_sidebar', type: :issues diff --git a/app/views/projects/issues/service_desk.html.haml b/app/views/projects/issues/service_desk.html.haml index 93cb5ddd7e2..3cc419716e5 100644 --- a/app/views/projects/issues/service_desk.html.haml +++ b/app/views/projects/issues/service_desk.html.haml @@ -1,7 +1,7 @@ - @can_bulk_update = false - page_title _("Service Desk") -- add_page_specific_style 'page_bundles/issues_list' +- add_page_specific_style 'page_bundles/issuable_list' - content_for :breadcrumbs_extra do = render "projects/issues/service_desk/nav_btns", show_export_button: false, show_rss_button: false diff --git a/app/views/projects/jobs/_table.html.haml b/app/views/projects/jobs/_table.html.haml index cd59eae1fb7..954c77a21f3 100644 --- a/app/views/projects/jobs/_table.html.haml +++ b/app/views/projects/jobs/_table.html.haml @@ -20,16 +20,16 @@ %table.table.ci-table.builds-page %thead %tr - %th Status - %th Name - %th Job - %th Pipeline + %th= _('Status') + %th= _('Name') + %th= _('Job') + %th= _('Pipeline') - if admin - %th Project - %th Runner - %th Stage - %th Duration - %th Coverage + %th= _('Project') + %th= _('Runner') + %th= _('Stage') + %th= _('Duration') + %th= _('Coverage') %th = render partial: "projects/ci/builds/build", collection: builds, as: :build, locals: { commit_sha: true, ref: true, pipeline_link: true, stage: true, allow_retry: true, admin: admin } 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 index 78fce3f7087..fb950611f81 100644 --- 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 @@ -1,53 +1,53 @@ - display_issuable_type = issuable_display_type(@merge_request) -.btn-group.gl-md-ml-3.gl-display-flex.dropdown.gl-new-dropdown.gl-md-w-auto.gl-w-full +.btn-group.gl-md-ml-3.gl-display-flex.dropdown.gl-dropdown.gl-md-w-auto.gl-w-full = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle btn-default-tertiary dropdown-icon-only dropdown-toggle-no-caret has-tooltip gl-display-none! gl-md-display-inline-flex!", data: { toggle: 'dropdown', title: _('Merge request actions'), testid: 'merge-request-actions', 'aria-label': _('Merge request actions') } do = sprite_icon "ellipsis_v", size: 16, css_class: "dropdown-icon gl-icon" = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md btn-block gl-button gl-dropdown-toggle gl-md-display-none!", data: { 'toggle' => 'dropdown' } do - %span.gl-new-dropdown-button-text= _('Merge request actions') + %span.gl-dropdown-button-text= _('Merge request actions') = sprite_icon "chevron-down", size: 16, css_class: "dropdown-icon gl-icon" .dropdown-menu.dropdown-menu-right - .gl-new-dropdown-inner - .gl-new-dropdown-contents + .gl-dropdown-inner + .gl-dropdown-contents %ul - if current_user && moved_mr_sidebar_enabled? - %li.gl-new-dropdown-item.js-sidebar-subscriptions-widget-root - %li.gl-new-dropdown-divider + %li.gl-dropdown-item.js-sidebar-subscriptions-widget-root + %li.gl-dropdown-divider %hr.dropdown-divider - if can?(current_user, :update_merge_request, @merge_request) - %li.gl-new-dropdown-item{ class: "gl-md-display-none!" } + %li.gl-dropdown-item{ class: "gl-md-display-none!" } = link_to edit_project_merge_request_path(@project, @merge_request), class: 'dropdown-item' do - .gl-new-dropdown-item-text-wrapper + .gl-dropdown-item-text-wrapper = _('Edit') - if @merge_request.open? - %li.gl-new-dropdown-item + %li.gl-dropdown-item = link_to toggle_draft_merge_request_path(@merge_request), method: :put, class: 'dropdown-item js-draft-toggle-button' do - .gl-new-dropdown-item-text-wrapper + .gl-dropdown-item-text-wrapper = @merge_request.draft? ? _('Mark as ready') : _('Mark as draft') - %li.gl-new-dropdown-item.js-close-item + %li.gl-dropdown-item.js-close-item = link_to close_issuable_path(@merge_request), method: :put, class: 'dropdown-item' do - .gl-new-dropdown-item-text-wrapper + .gl-dropdown-item-text-wrapper = _('Close') = display_issuable_type - elsif !@merge_request.source_project_missing? && @merge_request.closed? - %li.gl-new-dropdown-item + %li.gl-dropdown-item = link_to reopen_issuable_path(@merge_request), method: :put, class: 'dropdown-item' do - .gl-new-dropdown-item-text-wrapper + .gl-dropdown-item-text-wrapper = _('Reopen') = display_issuable_type - if moved_mr_sidebar_enabled? - %li.gl-new-dropdown-item.js-sidebar-lock-root - %li.gl-new-dropdown-item + %li.gl-dropdown-item.js-sidebar-lock-root + %li.gl-dropdown-item %button.dropdown-item.js-copy-reference{ type: "button", data: { 'clipboard-text': @merge_request.to_reference(full: true) } } - .gl-new-dropdown-item-text-wrapper + .gl-dropdown-item-text-wrapper = _('Copy reference') - unless current_controller?('conflicts') - unless issuable_author_is_current_user(@merge_request) - if moved_mr_sidebar_enabled? - %li.gl-new-dropdown-divider + %li.gl-dropdown-divider %hr.dropdown-divider - %li.gl-new-dropdown-item + %li.gl-dropdown-item = link_to new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)), class: 'dropdown-item' do - .gl-new-dropdown-item-text-wrapper - = _('Report abuse') + .gl-dropdown-item-text-wrapper + = _('Report abuse to administrator') diff --git a/app/views/projects/merge_requests/_code_dropdown.html.haml b/app/views/projects/merge_requests/_code_dropdown.html.haml index 5c7fe56095c..2ef89a7bf04 100644 --- a/app/views/projects/merge_requests/_code_dropdown.html.haml +++ b/app/views/projects/merge_requests/_code_dropdown.html.haml @@ -1,39 +1,39 @@ -.gl-md-ml-3.dropdown.gl-new-dropdown{ class: "gl-display-none! gl-md-display-flex!" } +.gl-md-ml-3.dropdown.gl-dropdown{ class: "gl-display-none! gl-md-display-flex!" } #js-check-out-modal{ data: how_merge_modal_data(@merge_request) } = button_tag type: 'button', class: "btn dropdown-toggle btn-confirm gl-button gl-dropdown-toggle", data: { toggle: 'dropdown', qa_selector: 'mr_code_dropdown' } do - %span.gl-new-dropdown-button-text= _('Code') + %span.gl-dropdown-button-text= _('Code') = sprite_icon "chevron-down", size: 16, css_class: "dropdown-icon gl-icon gl-ml-2 gl-mr-0!" .dropdown-menu.dropdown-menu-right - .gl-new-dropdown-inner - .gl-new-dropdown-contents + .gl-dropdown-inner + .gl-dropdown-contents %ul - %li.gl-new-dropdown-section-header + %li.gl-dropdown-section-header %header.dropdown-header = _('Review changes') - %li.gl-new-dropdown-item + %li.gl-dropdown-item %button.dropdown-item.js-check-out-modal-trigger{ type: 'button' } - .gl-new-dropdown-item-text-wrapper + .gl-dropdown-item-text-wrapper = _('Check out branch') - if current_user - %li.gl-new-dropdown-item + %li.gl-dropdown-item = link_to ide_merge_request_path(@merge_request), class: 'dropdown-item', target: '_blank', data: { qa_selector: 'open_in_web_ide_button' } do - .gl-new-dropdown-item-text-wrapper + .gl-dropdown-item-text-wrapper = _('Open in Web IDE') - if Gitlab::CurrentSettings.gitpod_enabled && current_user&.gitpod_enabled - %li.gl-new-dropdown-item + %li.gl-dropdown-item = link_to "#{Gitlab::CurrentSettings.gitpod_url}##{merge_request_url(@merge_request)}", target: '_blank', class: 'dropdown-item' do - .gl-new-dropdown-item-text-wrapper + .gl-dropdown-item-text-wrapper = _('Open in Gitpod') - %li.gl-new-dropdown-divider + %li.gl-dropdown-divider %hr.dropdown-divider - %li.gl-new-dropdown-section-header + %li.gl-dropdown-section-header %header.dropdown-header = _('Download') - %li.gl-new-dropdown-item + %li.gl-dropdown-item = link_to merge_request_path(@merge_request, format: :patch), class: 'dropdown-item', download: '', data: { qa_selector: 'download_email_patches_menu_item' } do - .gl-new-dropdown-item-text-wrapper + .gl-dropdown-item-text-wrapper = _('Email patches') - %li.gl-new-dropdown-item + %li.gl-dropdown-item = link_to merge_request_path(@merge_request, format: :diff), class: 'dropdown-item', download: '', data: { qa_selector: 'download_plain_diff_menu_item' } do - .gl-new-dropdown-item-text-wrapper + .gl-dropdown-item-text-wrapper = _('Plain diff') diff --git a/app/views/projects/merge_requests/_page.html.haml b/app/views/projects/merge_requests/_page.html.haml new file mode 100644 index 00000000000..9d79352659c --- /dev/null +++ b/app/views/projects/merge_requests/_page.html.haml @@ -0,0 +1,114 @@ +- @gfm_form = true +- unless moved_mr_sidebar_enabled? + - @content_class = "merge-request-container#{' limit-container-width' unless fluid_layout}" +- add_to_breadcrumbs _("Merge requests"), project_merge_requests_path(@project) +- breadcrumb_title @merge_request.to_reference +- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", _("Merge requests") +- page_description @merge_request.description_html +- page_card_attributes @merge_request.card_attributes +- suggest_changes_help_path = help_page_path('user/project/merge_requests/reviews/suggestions.md') +- mr_action = j(params[:tab].presence || 'show') +- add_page_specific_style 'page_bundles/issuable' +- add_page_specific_style 'page_bundles/design_management' +- add_page_specific_style 'page_bundles/merge_requests' +- add_page_specific_style 'page_bundles/pipelines' +- add_page_specific_style 'page_bundles/reports' +- add_page_specific_style 'page_bundles/ci_status' + +- add_page_startup_api_call @endpoint_metadata_url +- if mr_action == 'diffs' + - add_page_startup_api_call @endpoint_diff_batch_url + +.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 } } + - if moved_mr_sidebar_enabled? + #js-merge-sticky-header{ data: { data: sticky_header_data.to_json } } + = render "projects/merge_requests/mr_title" + + .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'} #{'gl-static' if moved_mr_sidebar_enabled?}" } + .merge-request-tabs-container.gl-display-flex.gl-justify-content-space-between{ class: "#{'is-merge-request' if Feature.enabled?(:moved_mr_sidebar, @project) && !fluid_layout}" } + %ul.merge-request-tabs.nav-tabs.nav.nav-links.gl-display-flex.gl-flex-nowrap.gl-m-0.gl-p-0{ class: "#{'gl-w-full gl-lg-w-auto!' if Feature.enabled?(:moved_mr_sidebar, @project)}" } + = render "projects/merge_requests/tabs/tab", class: "notes-tab", qa_selector: "notes_tab" do + = tab_link_for @merge_request, :show, force_link: @commit.present? do + = _("Overview") + = gl_badge_tag @merge_request.related_notes.user.count, { size: :sm }, { class: 'js-discussions-count' } + - if @merge_request.source_project + = render "projects/merge_requests/tabs/tab", name: "commits", class: "commits-tab", qa_selector: "commits_tab" do + = tab_link_for @merge_request, :commits do + = _("Commits") + = gl_badge_tag @commits_count, { size: :sm } + - if @project.builds_enabled? + = render "projects/merge_requests/tabs/tab", name: "pipelines", class: "pipelines-tab" do + = tab_link_for @merge_request, :pipelines do + = _("Pipelines") + = gl_badge_tag @number_of_pipelines, { size: :sm }, { class: 'js-pipelines-mr-count' } + = render "projects/merge_requests/tabs/tab", name: "diffs", class: "diffs-tab js-diffs-tab", id: "diffs-tab", qa_selector: "diffs_tab" do + = tab_link_for @merge_request, :diffs do + = _("Changes") + = gl_badge_tag @diffs_count, { size: :sm } + .d-flex.flex-wrap.align-items-center.justify-content-lg-end + #js-vue-discussion-counter{ data: { blocks_merge: @project.only_allow_merge_if_all_discussions_are_resolved?.to_s } } + - if moved_mr_sidebar_enabled? + - if !!@issuable_sidebar.dig(:current_user, :id) + .js-sidebar-todo-widget-root{ data: { project_path: @issuable_sidebar[:project_full_path], iid: @issuable_sidebar[:iid], id: @issuable_sidebar[:id] } } + .gl-ml-auto.gl-align-items-center.gl-display-none.gl-md-display-flex.gl-ml-3.js-expand-sidebar.gl-absolute.gl-right-5{ class: "gl-lg-display-none!" } + = render Pajamas::ButtonComponent.new(icon: 'chevron-double-lg-left', + button_options: { class: 'js-sidebar-toggle' }) do + = _('Expand') + .tab-content#diff-notes-app + #js-diff-file-finder + #js-code-navigation + = render "projects/merge_requests/tabs/pane", id: "notes", class: "notes voting_notes" do + %div{ class: "#{'merge-request-overview' if moved_mr_sidebar_enabled?}" } + %section + .issuable-discussion.js-vue-notes-event + - if @merge_request.description.present? + .detail-page-description.gl-pb-0 + = render "projects/merge_requests/description" + = render "projects/merge_requests/awards_block" + = render "projects/merge_requests/widget" + - if mr_action === "show" + - add_page_startup_api_call Feature.enabled?(:paginated_mr_discussions, @project) ? discussions_path(@merge_request, per_page: 20) : discussions_path(@merge_request) + - add_page_startup_api_call widget_project_json_merge_request_path(@project, @merge_request, format: :json) + - add_page_startup_api_call cached_widget_project_json_merge_request_path(@project, @merge_request, format: :json) + #js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request).to_json, + endpoint_metadata: @endpoint_metadata_url, + noteable_data: serialize_issuable(@merge_request, serializer: 'noteable'), + noteable_type: 'MergeRequest', + notes_filters: UserPreference.notes_filters.to_json, + notes_filter_value: current_user&.notes_filter_for(@merge_request), + target_type: 'merge_request', + help_page_path: suggest_changes_help_path, + current_user_data: @current_user_data, + is_locked: @merge_request.discussion_locked.to_s } } + - if moved_mr_sidebar_enabled? + = render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, reviewers: @merge_request.reviewers, source_branch: @merge_request.source_branch + + = render "projects/merge_requests/tabs/pane", name: "commits", id: "commits", class: "commits" do + -# This tab is always loaded via AJAX + = render "projects/merge_requests/tabs/pane", name: "pipelines", id: "pipelines", class: "pipelines" do + - if @project.builds_enabled? + = render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request) + - params = request.query_parameters.merge(diff_head: true) + = render "projects/merge_requests/tabs/pane", name: "diffs", id: "js-diffs-app", class: "diffs", data: diffs_tab_pane_data(@project, @merge_request, params) + + .mr-loading-status + .loading.hide + = gl_loading_icon(size: 'lg') + +- unless moved_mr_sidebar_enabled? + = render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, reviewers: @merge_request.reviewers, source_branch: @merge_request.source_branch + +- if @merge_request.can_be_reverted?(current_user) + = render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit +- if @merge_request.can_be_cherry_picked? + = render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit + +#js-review-bar + +- if current_user && Feature.enabled?(:mr_experience_survey, current_user) + #js-mr-experience-survey{ data: { account_age: current_user.account_age_in_days } } + += render 'projects/invite_members_modal', project: @project += render 'shared/web_ide_path' diff --git a/app/views/projects/merge_requests/creations/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml index 17b1e5a757c..48334023cf0 100644 --- a/app/views/projects/merge_requests/creations/_new_compare.html.haml +++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml @@ -1,7 +1,7 @@ %h1.page-title.gl-font-size-h-display = _('New merge request') -= form_for [@project, @merge_request], url: project_new_merge_request_path(@project), method: :get, html: { class: "merge-request-form js-requires-input" } do |f| += gitlab_ui_form_for [@project, @merge_request], url: project_new_merge_request_path(@project), method: :get, html: { class: "merge-request-form js-requires-input" } do |f| - if params[:nav_source].present? = hidden_field_tag(:nav_source, params[:nav_source]) .js-merge-request-new-compare.row{ 'data-source-branch-url': project_new_merge_request_branch_from_path(@source_project), 'data-target-branch-url': project_new_merge_request_branch_to_path(@source_project) } @@ -40,17 +40,20 @@ %h2.gl-font-size-h2 = _('Target branch') .clearfix - - projects = target_projects(@project) .merge-request-select.dropdown - = f.hidden_field :target_project_id - = dropdown_toggle f.object.target_project.full_path, { toggle: "dropdown", 'field-name': "#{f.object_name}[target_project_id]", disabled: @merge_request.persisted?, default_text: _("Select target project") }, { toggle_class: "js-compare-dropdown js-target-project" } - .dropdown-menu.dropdown-menu-selectable.dropdown-target-project - = dropdown_title(_("Select target project")) - = dropdown_filter(_("Search projects")) - = dropdown_content do - = render 'projects/merge_requests/dropdowns/project', - projects: projects, - selected: f.object.target_project_id + - if Feature.enabled?(:mr_compare_dropdowns, @project) + #js-target-project-dropdown{ data: { target_projects_path: project_new_merge_request_json_target_projects_path(@project), current_project: { value: f.object.target_project_id.to_s, text: f.object.target_project.full_path }.to_json } } + - else + - projects = target_projects(@project) + = f.hidden_field :target_project_id + = dropdown_toggle f.object.target_project.full_path, { toggle: "dropdown", 'field-name': "#{f.object_name}[target_project_id]", disabled: @merge_request.persisted?, default_text: _("Select target project") }, { toggle_class: "js-compare-dropdown js-target-project" } + .dropdown-menu.dropdown-menu-selectable.dropdown-target-project + = dropdown_title(_("Select target project")) + = dropdown_filter(_("Search projects")) + = dropdown_content do + = render 'projects/merge_requests/dropdowns/project', + projects: projects, + selected: f.object.target_project_id .merge-request-select.dropdown = f.hidden_field :target_branch = dropdown_toggle f.object.target_branch.presence || _("Select target branch"), { toggle: "dropdown", 'field-name': "#{f.object_name}[target_branch]", 'refs-url': refs_project_path(f.object.target_project), selected: f.object.target_branch, default_text: _("Select target branch") }, { toggle_class: "js-compare-dropdown js-target-branch monospace" } @@ -68,4 +71,4 @@ - if @merge_request.errors.any? = form_errors(@merge_request) - = f.submit _('Compare branches and continue'), class: "gl-button btn btn-confirm mr-compare-btn gl-mt-4", data: { qa_selector: "compare_branches_button" } + = f.submit _('Compare branches and continue'), data: { qa_selector: 'compare_branches_button' }, pajamas_button: true diff --git a/app/views/projects/merge_requests/diffs.html.haml b/app/views/projects/merge_requests/diffs.html.haml new file mode 100644 index 00000000000..1ef212ee5ce --- /dev/null +++ b/app/views/projects/merge_requests/diffs.html.haml @@ -0,0 +1 @@ += render 'page' diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml index a3f40207d20..79da09c5205 100644 --- a/app/views/projects/merge_requests/index.html.haml +++ b/app/views/projects/merge_requests/index.html.haml @@ -5,6 +5,7 @@ - page_title _("Merge requests") - new_merge_request_email = @project.new_issuable_address(current_user, 'merge_request') +- add_page_specific_style 'page_bundles/issuable_list' = content_for :meta_tags do = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@project.name} merge requests") diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 203724fc1f1..1ef212ee5ce 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -1,113 +1 @@ -- @gfm_form = true -- unless moved_mr_sidebar_enabled? - - @content_class = "merge-request-container#{' limit-container-width' unless fluid_layout}" -- add_to_breadcrumbs _("Merge requests"), project_merge_requests_path(@project) -- breadcrumb_title @merge_request.to_reference -- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", _("Merge requests") -- page_description @merge_request.description_html -- page_card_attributes @merge_request.card_attributes -- suggest_changes_help_path = help_page_path('user/project/merge_requests/reviews/suggestions.md') -- mr_action = j(params[:tab].presence || 'show') -- add_page_specific_style 'page_bundles/design_management' -- add_page_specific_style 'page_bundles/merge_requests' -- add_page_specific_style 'page_bundles/pipelines' -- add_page_specific_style 'page_bundles/reports' -- add_page_specific_style 'page_bundles/ci_status' - -- add_page_startup_api_call @endpoint_metadata_url -- if mr_action == 'diffs' - - add_page_startup_api_call @endpoint_diff_batch_url - -.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 } } - - if moved_mr_sidebar_enabled? - #js-merge-sticky-header{ data: { data: sticky_header_data.to_json } } - = render "projects/merge_requests/mr_title" - - .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'} #{'gl-static' if moved_mr_sidebar_enabled?}" } - .merge-request-tabs-container.gl-display-flex.gl-justify-content-space-between{ class: "#{'is-merge-request' if Feature.enabled?(:moved_mr_sidebar, @project) && !fluid_layout}" } - %ul.merge-request-tabs.nav-tabs.nav.nav-links.gl-display-flex.gl-flex-nowrap.gl-m-0.gl-p-0{ class: "#{'gl-w-full gl-lg-w-auto!' if Feature.enabled?(:moved_mr_sidebar, @project)}" } - = render "projects/merge_requests/tabs/tab", class: "notes-tab", qa_selector: "notes_tab" do - = tab_link_for @merge_request, :show, force_link: @commit.present? do - = _("Overview") - = gl_badge_tag @merge_request.related_notes.user.count, { size: :sm } - - if @merge_request.source_project - = render "projects/merge_requests/tabs/tab", name: "commits", class: "commits-tab", qa_selector: "commits_tab" do - = tab_link_for @merge_request, :commits do - = _("Commits") - = gl_badge_tag @commits_count, { size: :sm } - - if @project.builds_enabled? - = render "projects/merge_requests/tabs/tab", name: "pipelines", class: "pipelines-tab" do - = tab_link_for @merge_request, :pipelines do - = _("Pipelines") - = gl_badge_tag @number_of_pipelines, { size: :sm }, { class: 'js-pipelines-mr-count' } - = render "projects/merge_requests/tabs/tab", name: "diffs", class: "diffs-tab js-diffs-tab", id: "diffs-tab", qa_selector: "diffs_tab" do - = tab_link_for @merge_request, :diffs do - = _("Changes") - = gl_badge_tag @diffs_count, { size: :sm } - .d-flex.flex-wrap.align-items-center.justify-content-lg-end - #js-vue-discussion-counter{ data: { blocks_merge: @project.only_allow_merge_if_all_discussions_are_resolved?.to_s } } - - if moved_mr_sidebar_enabled? - - if !!@issuable_sidebar.dig(:current_user, :id) - .js-sidebar-todo-widget-root{ data: { project_path: @issuable_sidebar[:project_full_path], iid: @issuable_sidebar[:iid], id: @issuable_sidebar[:id] } } - .gl-ml-auto.gl-align-items-center.gl-display-none.gl-md-display-flex.gl-ml-3.js-expand-sidebar{ class: "gl-lg-display-none!" } - = render Pajamas::ButtonComponent.new(icon: 'chevron-double-lg-left', - button_options: { class: 'js-sidebar-toggle' }) do - = _('Expand') - .tab-content#diff-notes-app - #js-diff-file-finder - #js-code-navigation - = render "projects/merge_requests/tabs/pane", id: "notes", class: "notes voting_notes" do - %div{ class: "#{'merge-request-overview' if moved_mr_sidebar_enabled?}" } - %section - .issuable-discussion.js-vue-notes-event - - if @merge_request.description.present? - .detail-page-description.gl-pb-0 - = render "projects/merge_requests/description" - = render "projects/merge_requests/awards_block" - = render "projects/merge_requests/widget" - - if mr_action === "show" - - add_page_startup_api_call Feature.enabled?(:paginated_mr_discussions, @project) ? discussions_path(@merge_request, per_page: 20) : discussions_path(@merge_request) - - add_page_startup_api_call widget_project_json_merge_request_path(@project, @merge_request, format: :json) - - add_page_startup_api_call cached_widget_project_json_merge_request_path(@project, @merge_request, format: :json) - #js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request).to_json, - endpoint_metadata: @endpoint_metadata_url, - noteable_data: serialize_issuable(@merge_request, serializer: 'noteable'), - noteable_type: 'MergeRequest', - notes_filters: UserPreference.notes_filters.to_json, - notes_filter_value: current_user&.notes_filter_for(@merge_request), - target_type: 'merge_request', - help_page_path: suggest_changes_help_path, - current_user_data: @current_user_data, - is_locked: @merge_request.discussion_locked.to_s } } - - if moved_mr_sidebar_enabled? - = render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, reviewers: @merge_request.reviewers, source_branch: @merge_request.source_branch - - = render "projects/merge_requests/tabs/pane", name: "commits", id: "commits", class: "commits" do - -# This tab is always loaded via AJAX - = render "projects/merge_requests/tabs/pane", name: "pipelines", id: "pipelines", class: "pipelines" do - - if @project.builds_enabled? - = render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request) - - params = request.query_parameters.merge(diff_head: true) - = render "projects/merge_requests/tabs/pane", name: "diffs", id: "js-diffs-app", class: "diffs", data: diffs_tab_pane_data(@project, @merge_request, params) - - .mr-loading-status - .loading.hide - = gl_loading_icon(size: 'lg') - -- unless moved_mr_sidebar_enabled? - = render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, reviewers: @merge_request.reviewers, source_branch: @merge_request.source_branch - -- if @merge_request.can_be_reverted?(current_user) - = render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit -- if @merge_request.can_be_cherry_picked? - = render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit - -#js-review-bar - -- if current_user && Feature.enabled?(:mr_experience_survey, current_user) - #js-mr-experience-survey{ data: { account_age: current_user.account_age_in_days } } - -= render 'projects/invite_members_modal', project: @project -= render 'shared/web_ide_path' += render 'page' diff --git a/app/views/projects/ml/candidates/show.html.haml b/app/views/projects/ml/candidates/show.html.haml new file mode 100644 index 00000000000..7fa98f69edf --- /dev/null +++ b/app/views/projects/ml/candidates/show.html.haml @@ -0,0 +1,7 @@ +- experiment = @candidate.experiment +- add_to_breadcrumbs _("Experiments"), project_ml_experiments_path(@project) +- add_to_breadcrumbs experiment.name, project_ml_experiment_path(@project, experiment.iid) +- breadcrumb_title "Candidate #{@candidate.iid}" +- data = candidate_as_data(@candidate) + +#js-show-ml-candidate{ data: { candidate: data } } diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml index 2a3171e9fd8..70bb97b7625 100644 --- a/app/views/projects/network/show.html.haml +++ b/app/views/projects/network/show.html.haml @@ -1,10 +1,11 @@ - breadcrumb_title _("Graph") - page_title _("Graph"), @ref +- network_path = Feature.enabled?(:use_ref_type_parameter) ? project_network_path(@project, @id, ref_type: @ref_type) : project_network_path(@project, @id) = render "head" .gl-mt-5 .project-network.gl-border-1.gl-border-solid.gl-border-gray-300 .controls.gl-bg-gray-50.gl-p-2.gl-font-base.gl-text-gray-400.gl-border-b-1.gl-border-b-solid.gl-border-b-gray-300 - = form_tag project_network_path(@project, @id), method: :get, class: 'form-inline network-form' do |f| + = form_tag network_path, method: :get, class: 'form-inline network-form' do |f| = text_field_tag :extended_sha1, @options[:extended_sha1], placeholder: _("Git revision"), class: 'search-input form-control gl-form-input input-mx-250 search-sha gl-mr-2' = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, icon: 'search') .inline.gl-ml-5 diff --git a/app/views/projects/notes/_more_actions_dropdown.html.haml b/app/views/projects/notes/_more_actions_dropdown.html.haml index 5f70e25f802..2351bd209a7 100644 --- a/app/views/projects/notes/_more_actions_dropdown.html.haml +++ b/app/views/projects/notes/_more_actions_dropdown.html.haml @@ -10,7 +10,7 @@ - unless is_current_user %li = link_to new_abuse_report_path(user_id: note.author.id, ref_url: noteable_note_url(note)) do - = _('Report abuse to admin') + = _('Report abuse to administrator') - if note_editable %li = link_to note_url(note), method: :delete, data: { confirm: _('Are you sure you want to delete this comment?'), confirm_btn_variant: 'danger', qa_selector: 'delete_comment_button' }, aria: { label: _('Delete comment') }, remote: true, class: 'js-note-delete' do diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml index 16312da1353..32e67fdadb8 100644 --- a/app/views/projects/pages/_list.html.haml +++ b/app/views/projects/pages/_list.html.haml @@ -12,7 +12,7 @@ - if verification_enabled - tooltip, status = domain.unverified? ? [s_('GitLabPages|Unverified'), 'failed'] : [s_('GitLabPages|Verified'), 'success'] .domain-status.ci-status-icon.has-tooltip{ class: "gl-mr-5 ci-status-icon-#{status}", title: tooltip } - = sprite_icon("status_#{status}" ) + = sprite_icon("status_#{status}") .domain-name = external_link(domain.url, domain.url) - if domain.certificate diff --git a/app/views/projects/pages_domains/new.html.haml b/app/views/projects/pages_domains/new.html.haml index 6de8117df6b..c88255e23f9 100644 --- a/app/views/projects/pages_domains/new.html.haml +++ b/app/views/projects/pages_domains/new.html.haml @@ -4,9 +4,8 @@ = _("New Pages Domain") = render 'projects/pages_domains/helper_text' %div - = form_for [@project, domain_presenter], html: { class: 'fieldset-form' } do |f| + = gitlab_ui_form_for [@project, domain_presenter], html: { class: 'fieldset-form' } do |f| = render 'form', { f: f } - .form-actions - = f.submit _('Create New Domain'), class: "gl-button btn btn-confirm" - .float-right - = link_to _('Cancel'), project_pages_path(@project), class: 'gl-button btn btn-default btn-cancel' + .form-actions.gl-display-flex + = f.submit _('Create New Domain'), class: 'gl-mr-3', pajamas_button: true + = link_to _('Cancel'), project_pages_path(@project), class: 'gl-button btn btn-default btn-cancel' diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml index 0edf75c9abc..5de5188ae6a 100644 --- a/app/views/projects/pages_domains/show.html.haml +++ b/app/views/projects/pages_domains/show.html.haml @@ -15,8 +15,8 @@ = _('Pages Domain') = render 'projects/pages_domains/helper_text' %div - = form_for [@project, domain_presenter], html: { class: 'fieldset-form' } do |f| + = gitlab_ui_form_for [@project, domain_presenter], html: { class: 'fieldset-form' } do |f| = render 'form', { f: f } - .form-actions.d-flex.justify-content-between - = f.submit _('Save Changes'), class: "gl-button btn btn-confirm" + .form-actions.gl-display-flex + = f.submit _('Save Changes'), class: 'gl-mr-3', pajamas_button: true = link_to _('Cancel'), project_pages_path(@project), class: 'gl-button btn btn-default btn-inverse' diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml index 7b16564dfa2..0de31f59033 100644 --- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml +++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml @@ -1,33 +1,38 @@ - if pipeline_schedule %tr.pipeline-schedule-table-row - %td - = pipeline_schedule.description - %td.branch-name-cell.gl-text-truncate - - if pipeline_schedule.for_tag? - = sprite_icon('tag', size: 12, css_class: 'gl-vertical-align-middle!' ) - - else - = sprite_icon('fork', size: 12, css_class: 'gl-vertical-align-middle!') - - if pipeline_schedule.ref.present? - = link_to pipeline_schedule.ref_for_display, project_ref_path(@project, pipeline_schedule.ref_for_display), class: "ref-name" - %td - - if pipeline_schedule.last_pipeline - .status-icon-container{ class: "ci-status-icon-#{pipeline_schedule.last_pipeline.status}" } - = link_to project_pipeline_path(@project, pipeline_schedule.last_pipeline.id) do - = ci_icon_for_status(pipeline_schedule.last_pipeline.status) - %span ##{pipeline_schedule.last_pipeline.id} - - else - = s_("PipelineSchedules|None") - %td.gl-text-gray-500{ 'data-testid': 'next-run-cell' } - - if pipeline_schedule.active? && pipeline_schedule.next_run_at - = time_ago_with_tooltip(pipeline_schedule.real_next_run) - - else - = s_("PipelineSchedules|Inactive") - %td - - if pipeline_schedule.owner - = render Pajamas::AvatarComponent.new(pipeline_schedule.owner, size: 24, class: "gl-mr-2") - = link_to user_path(pipeline_schedule.owner) do - = pipeline_schedule.owner&.name - %td + %td{ role: 'cell', data: { label: _('Description') } } + %div + = pipeline_schedule.description + %td.branch-name-cell.gl-text-truncate{ role: 'cell', data: { label: s_("PipelineSchedules|Target") } } + %div + - if pipeline_schedule.for_tag? + = sprite_icon('tag', size: 12, css_class: 'gl-vertical-align-middle!') + - else + = sprite_icon('fork', size: 12, css_class: 'gl-vertical-align-middle!') + - if pipeline_schedule.ref.present? + = link_to pipeline_schedule.ref_for_display, project_ref_path(@project, pipeline_schedule.ref_for_display), class: "ref-name" + %td{ role: 'cell', data: { label: _("Last Pipeline") } } + %div + - if pipeline_schedule.last_pipeline + .status-icon-container{ class: "ci-status-icon-#{pipeline_schedule.last_pipeline.status}" } + = link_to project_pipeline_path(@project, pipeline_schedule.last_pipeline.id) do + = ci_icon_for_status(pipeline_schedule.last_pipeline.status) + %span.gl-text-blue-500! ##{pipeline_schedule.last_pipeline.id} + - else + = s_("PipelineSchedules|None") + %td.gl-text-gray-500{ role: 'cell', data: { label: s_("PipelineSchedules|Next Run") }, 'data-testid': 'next-run-cell' } + %div + - if pipeline_schedule.active? && pipeline_schedule.next_run_at + = time_ago_with_tooltip(pipeline_schedule.real_next_run) + - else + = s_("PipelineSchedules|Inactive") + %td{ role: 'cell', data: { label: _("Owner") } } + %div + - if pipeline_schedule.owner + = render Pajamas::AvatarComponent.new(pipeline_schedule.owner, size: 24, class: "gl-mr-2") + = link_to user_path(pipeline_schedule.owner) do + = pipeline_schedule.owner&.name + %td{ role: 'cell', data: { label: _('Actions') } } .float-right.btn-group - if can?(current_user, :play_pipeline_schedule, pipeline_schedule) = link_to play_pipeline_schedule_path(pipeline_schedule), method: :post, title: _('Play'), class: 'btn gl-button btn-default btn-icon' do diff --git a/app/views/projects/pipeline_schedules/_table.html.haml b/app/views/projects/pipeline_schedules/_table.html.haml index d0c7ea77263..2f96ac6a534 100644 --- a/app/views/projects/pipeline_schedules/_table.html.haml +++ b/app/views/projects/pipeline_schedules/_table.html.haml @@ -1,12 +1,12 @@ .table-holder - %table.table.ci-table - %thead - %tr - %th= _("Description") - %th= s_("PipelineSchedules|Target") - %th= _("Last Pipeline") - %th= s_("PipelineSchedules|Next Run") - %th= _("Owner") - %th - + %table.table.ci-table.responsive-table.b-table.gl-table.b-table-stacked-md{ role: 'table' } + %thead{ role: 'rowgroup' } + %tr{ role: 'row' } + %th.table-th-transparent.border-bottom{ role: 'cell', style: 'width: 34%' }= _("Description") + %th.table-th-transparent.border-bottom{ role: 'cell' }= s_("PipelineSchedules|Target") + %th.table-th-transparent.border-bottom{ role: 'cell' }= _("Last Pipeline") + %th.table-th-transparent.border-bottom{ role: 'cell' }= s_("PipelineSchedules|Next Run") + %th.table-th-transparent.border-bottom{ role: 'cell' }= _("Owner") + %th.table-th-transparent.border-bottom{ role: 'cell' } + %tbody{ role: 'rowgroup' } = render partial: "pipeline_schedule", collection: @schedules diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml index 47ad8cc826d..cb7cd631859 100644 --- a/app/views/projects/pipeline_schedules/index.html.haml +++ b/app/views/projects/pipeline_schedules/index.html.haml @@ -21,8 +21,7 @@ %ul.content-list = render partial: "table" - else - = render Pajamas::CardComponent.new(card_options: { class: 'bg-light gl-mt-3 gl-text-center' }) do |c| - - c.body do - = _("No schedules") + .nothing-here-block + = _("No schedules") #pipeline-take-ownership-modal diff --git a/app/views/projects/pipeline_schedules/new.html.haml b/app/views/projects/pipeline_schedules/new.html.haml index d3757d0e339..2d4ed5a9872 100644 --- a/app/views/projects/pipeline_schedules/new.html.haml +++ b/app/views/projects/pipeline_schedules/new.html.haml @@ -9,6 +9,6 @@ = _("Schedule a new pipeline") - if Feature.enabled?(:pipeline_schedules_vue, @project) - #pipeline-schedules-form-new{ data: { full_path: @project.full_path } } + #pipeline-schedules-form-new{ data: { full_path: @project.full_path, cron: @schedule.cron, daily_limit: @schedule.daily_limit, timezone_data: timezone_data.to_json, cron_timezone: @schedule.cron_timezone, project_id: @project.id, default_branch: @project.default_branch, settings_link: project_settings_ci_cd_path(@project), } } - else = render "form" diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 30cc7f94311..1a079324a0f 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -1,6 +1,7 @@ - if Feature.enabled?(:pipeline_name, @pipeline.project) && @pipeline.name - %h3 - = @pipeline.name + .gl-border-t.gl-p-5.gl-px-0 + %h3.gl-m-0.gl-text-body + = @pipeline.name - else .commit-box %h3.commit-title @@ -45,7 +46,7 @@ - popover_content_text = _('Learn more about Auto DevOps') = gl_badge_tag s_('Pipelines|Auto DevOps'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-autodevops', href: "#", tabindex: "0", role: "button", data: { container: 'body', toggle: 'popover', placement: 'top', html: 'true', triggers: 'focus', title: "<div class='gl-font-weight-normal gl-line-height-normal'>#{popover_title_text}</div>", content: "<a href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>" } } - if @pipeline.detached_merge_request_pipeline? - = gl_badge_tag s_('Pipelines|merge request'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-mergerequest has-tooltip', title: s_("Pipelines|This pipeline ran on the contents of this merge request's source branch, not the target branch.") } + = gl_badge_tag s_('Pipelines|merge request'), { variant: :info, size: :sm }, { class: 'js-pipeline-url-mergerequest has-tooltip', data: { qa_selector: 'merge_request_badge_tag' }, title: s_("Pipelines|This pipeline ran on the contents of this merge request's source branch, not the target branch.") } - if @pipeline.stuck? = gl_badge_tag s_('Pipelines|stuck'), { variant: :warning, size: :sm }, { class: 'js-pipeline-url-stuck has-tooltip' } diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml deleted file mode 100644 index e83547fd8f8..00000000000 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ /dev/null @@ -1,48 +0,0 @@ -- return if pipeline_has_errors - -.tabs-holder - %ul.pipelines-tabs.nav-links.no-top.no-bottom.mobile-separator.nav.nav-tabs - %li.js-pipeline-tab-link - = link_to project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-pipeline', action: 'pipelines', toggle: 'tab' }, class: 'pipeline-tab' do - = _('Pipeline') - %li.js-dag-tab-link - = link_to dag_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-dag', action: 'dag', toggle: 'tab' }, class: 'dag-tab' do - = _('Needs') - %li.js-builds-tab-link - = link_to builds_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do - = _('Jobs') - = gl_badge_tag @pipeline.total_size, { size: :sm }, { class: 'js-builds-counter' } - - if @pipeline.failed_builds.present? - %li.js-failures-tab-link - = link_to failures_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do - = _('Failed Jobs') - = gl_badge_tag @pipeline.failed_builds.count, { size: :sm }, { class: 'js-failures-counter' } - %li.js-tests-tab-link - = link_to test_report_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-tests', action: 'test_report', toggle: 'tab' }, class: 'test-tab' do - = s_('TestReports|Tests') - = gl_badge_tag @pipeline.test_report_summary.total[:count], { size: :sm }, { class: 'js-test-report-badge-counter' } - = render_if_exists "projects/pipelines/tabs_holder", pipeline: @pipeline, project: @project - -.tab-content - #js-tab-pipeline.tab-pane.gl-w-full - #js-pipeline-graph-vue - - #js-tab-builds.tab-pane - - if stages.present? - #js-pipeline-jobs-vue{ data: { full_path: @project.full_path, pipeline_iid: @pipeline.iid } } - - - if @pipeline.failed_builds.present? - #js-tab-failures.tab-pane - #js-pipeline-failed-jobs-vue{ data: { full_path: @project.full_path, pipeline_iid: @pipeline.iid, failed_jobs_summary_data: prepare_failed_jobs_summary_data(@pipeline.failed_builds) } } - - #js-tab-dag.tab-pane - #js-pipeline-dag-vue{ data: { pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, empty_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'), about_dag_doc_path: help_page_path('ci/directed_acyclic_graph/index.md'), dag_doc_path: help_page_path('ci/yaml/index.md', anchor: 'needs')} } - - #js-tab-tests.tab-pane - #js-pipeline-tests-detail{ data: { summary_endpoint: summary_project_pipeline_tests_path(@project, @pipeline, format: :json), - suite_endpoint: project_pipeline_test_path(@project, @pipeline, suite_name: 'suite', format: :json), - blob_path: project_blob_path(@project, @pipeline.sha), - has_test_report: @pipeline.complete_and_has_reports?(Ci::JobArtifact.of_report_type(:test)).to_s, - empty_state_image_path: image_path('illustrations/empty-state/empty-test-cases-lg.svg'), - artifacts_expired_image_path: image_path('illustrations/pipeline.svg') } } - = render_if_exists "projects/pipelines/tabs_content", pipeline: @pipeline, project: @project diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index 4531bb2d0a9..9b0a81a2f60 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -9,7 +9,7 @@ - add_page_startup_graphql_call('pipelines/get_pipeline_details', { projectPath: @project.full_path, iid: @pipeline.iid }) .js-pipeline-container{ data: { controller_action: "#{controller.action_name}" } } - #js-pipeline-header-vue.pipeline-header-container{ data: { full_path: @project.full_path, pipeline_iid: @pipeline.iid, pipeline_id: @pipeline.id, pipelines_path: project_pipelines_path(@project) } } + #js-pipeline-header-vue.pipeline-header-container{ data: { full_path: @project.full_path, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline), pipeline_iid: @pipeline.iid, pipeline_id: @pipeline.id, pipelines_path: project_pipelines_path(@project) } } = render_if_exists 'projects/pipelines/cc_validation_required_alert', pipeline: @pipeline @@ -18,16 +18,10 @@ - if pipeline_has_errors .bs-callout.bs-callout-danger - %h4= _('Found errors in your %{gitlab_ci_yml}:') % { gitlab_ci_yml: '.gitlab-ci.yml' } + %h4= _('Unable to create pipeline') %ul - @pipeline.yaml_errors.split("\n").each do |error| %li= error - - lint_link_url = project_ci_pipeline_editor_path(@project, tab: "LINT_TAB") - - lint_link_start = '<a href="%{url}" class="gl-text-blue-500!">'.html_safe % { url: lint_link_url } - = s_('You can also test your %{gitlab_ci_yml} in %{lint_link_start}CI Lint%{lint_link_end}').html_safe % { gitlab_ci_yml: '.gitlab-ci.yml', lint_link_start: lint_link_start, lint_link_end: '</a>'.html_safe } - - if Feature.enabled?(:pipeline_tabs_vue, @project) - #js-pipeline-tabs{ data: js_pipeline_tabs_data(@project, @pipeline, @current_user) } - else - = render "projects/pipelines/with_tabs", pipeline: @pipeline, stages: @stages, pipeline_has_errors: pipeline_has_errors -.js-pipeline-details-vue{ data: { metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: @project.namespace, project_id: @project, format: :json), pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline), pipeline_path: pipeline_path(@pipeline) } } + #js-pipeline-tabs{ data: js_pipeline_tabs_data(@project, @pipeline, @current_user) } diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index c7818602f52..4ac0e28d386 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -15,17 +15,17 @@ - invite_group_top_margin = '' - if can_admin_project_member?(@project) .js-import-project-members-trigger{ data: { classes: 'gl-md-w-auto gl-w-full' } } - .js-import-project-members-modal{ data: { project_id: @project.id, project_name: @project.name } } + .js-import-project-members-modal{ data: { project_id: @project.id, project_name: @project.name, reload_page_on_submit: true.to_s } } - invite_group_top_margin = 'gl-md-mt-0 gl-mt-3' - if @project.allowed_to_share_with_group? .js-invite-group-trigger{ data: { classes: "gl-md-w-auto gl-w-full gl-md-ml-3 #{invite_group_top_margin}", display_text: _('Invite a group') } } - = render 'projects/invite_groups_modal', project: @project + = render 'projects/invite_groups_modal', project: @project, reload_page_on_submit: true - if can_admin_project_member?(@project) .js-invite-members-trigger{ data: { variant: 'confirm', classes: 'gl-md-w-auto gl-w-full gl-md-ml-3 gl-md-mt-0 gl-mt-3', trigger_source: 'project-members-page', display_text: _('Invite members') } } - = render 'projects/invite_members_modal', project: @project + = render 'projects/invite_members_modal', project: @project, reload_page_on_submit: true - else - if project_can_be_shared? %h4 diff --git a/app/views/projects/protected_branches/_branches_list.html.haml b/app/views/projects/protected_branches/_branches_list.html.haml deleted file mode 100644 index 24d2b971472..00000000000 --- a/app/views/projects/protected_branches/_branches_list.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -- can_admin_project = can?(current_user, :admin_project, @project) - -= render layout: 'projects/protected_branches/shared/branches_list', locals: { can_admin_project: can_admin_project } do - = render partial: 'projects/protected_branches/protected_branch', collection: @protected_branches diff --git a/app/views/projects/protected_branches/_index.html.haml b/app/views/projects/protected_branches/_index.html.haml deleted file mode 100644 index 2b0a502fe4d..00000000000 --- a/app/views/projects/protected_branches/_index.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -- content_for :create_protected_branch do - = render 'projects/protected_branches/create_protected_branch' - -- content_for :branches_list do - = render "projects/protected_branches/branches_list" - -= render 'projects/protected_branches/shared/index' diff --git a/app/views/projects/protected_branches/_protected_branch.html.haml b/app/views/projects/protected_branches/_protected_branch.html.haml deleted file mode 100644 index 366d7a7a2eb..00000000000 --- a/app/views/projects/protected_branches/_protected_branch.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -= render layout: 'projects/protected_branches/shared/protected_branch', locals: { protected_branch: protected_branch } do - = render_if_exists 'projects/protected_branches/update_protected_branch', protected_branch: protected_branch diff --git a/app/views/projects/protected_branches/_update_protected_branch.html.haml b/app/views/projects/protected_branches/_update_protected_branch.html.haml deleted file mode 100644 index b2ec98be056..00000000000 --- a/app/views/projects/protected_branches/_update_protected_branch.html.haml +++ /dev/null @@ -1 +0,0 @@ -= render 'shared/projects/protected_branches/update_protected_branch', protected_branch: protected_branch diff --git a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml index 9ea7f397c0a..1db1da5e428 100644 --- a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml +++ b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml @@ -1,4 +1,4 @@ -= form_for [@project, @protected_tag], html: { class: 'new-protected-tag js-new-protected-tag' } do |f| += gitlab_ui_form_for [@project, @protected_tag], html: { class: 'new-protected-tag js-new-protected-tag' } do |f| %input{ type: 'hidden', name: 'update_section', value: 'js-protected-tags-settings' } = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }) do |c| - c.header do @@ -20,4 +20,4 @@ = yield :create_access_levels - c.footer do - = f.submit _('Protect'), class: 'gl-button btn btn-confirm', disabled: true, data: { qa_selector: 'protect_tag_button' } + = f.submit _('Protect'), pajamas_button: true, disabled: true, data: { qa_selector: 'protect_tag_button' } diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index 51f0b6319a1..910aab6da72 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -1,6 +1,6 @@ - page_title _("Container Registry") - @content_class = "limit-container-width" unless fluid_layout -- add_page_startup_graphql_call('container_registry/get_container_repositories', { fullPath: @project.full_path, first: 10, name: nil, isGroupPage: false, sort: nil} ) +- add_page_startup_graphql_call('container_registry/get_container_repositories', { fullPath: @project.full_path, first: 10, name: nil, isGroupPage: false, sort: nil}) %section #js-container-registry{ data: { endpoint: project_container_registry_index_path(@project), @@ -15,7 +15,6 @@ "expiration_policy_help_page_path" => help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'cleanup-policy'), "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'), - "container_registry_importing_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'tags-temporarily-cannot-be-marked-for-deletion'), "project_path": @project.full_path, "gid_prefix": container_repository_gid_prefix, "is_admin": current_user&.admin.to_s, diff --git a/app/views/projects/runners/_group_runners.html.haml b/app/views/projects/runners/_group_runners.html.haml index 5acd6f95df4..d71bcd12e64 100644 --- a/app/views/projects/runners/_group_runners.html.haml +++ b/app/views/projects/runners/_group_runners.html.haml @@ -35,7 +35,9 @@ = _('Ask your group owner to set up a group runner.') - else - %h4.underlined-title - = _('Available group runners: %{runners}').html_safe % { runners: @group_runners.count } - %ul.bordered-list - = render partial: 'projects/runners/runner', collection: @group_runners, as: :runner + %div{ data: { testid: 'group-runners' } } + %h5.gl-mt-6.gl-mb-0 + = _('Available group runners: %{runners}') % { runners: @group_runners_count } + %ul.bordered-list + = render partial: 'projects/runners/runner', collection: @group_runners, as: :runner + = paginate @group_runners, theme: "gitlab", param_name: "group_runners_page", params: { expand_runners: true, anchor: 'js-runners-settings' } diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml index 18803bdd8f3..e517b37aae9 100644 --- a/app/views/projects/runners/_runner.html.haml +++ b/app/views/projects/runners/_runner.html.haml @@ -7,7 +7,7 @@ - else %span = "##{runner.id} (#{runner.short_sha})" - - if runner.locked? + - if runner.locked? && runner.project_type? %span.has-tooltip{ title: s_('Runners|Runner is locked and available for currently assigned projects only. Only administrators can change the assigned projects.') } = sprite_icon('lock') .gl-ml-2 diff --git a/app/views/projects/runners/_shared_runners.html.haml b/app/views/projects/runners/_shared_runners.html.haml index 4689e70d907..9e7bbd6cefe 100644 --- a/app/views/projects/runners/_shared_runners.html.haml +++ b/app/views/projects/runners/_shared_runners.html.haml @@ -5,6 +5,9 @@ - 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.') - else - %h5.gl-mt-6.gl-mb-0 #{_('Available shared runners:')} #{@shared_runners_count} - %ul.bordered-list.available-shared-runners - = render partial: 'projects/runners/runner', collection: @shared_runners, as: :runner + %div{ data: { testid: 'available-shared-runners' } } + %h5.gl-mt-6.gl-mb-0 + = s_('Runners|Available shared runners: %{count}') % {count: @shared_runners_count} + %ul.bordered-list + = render partial: 'projects/runners/runner', collection: @shared_runners, as: :runner + = paginate @shared_runners, theme: "gitlab", param_name: "shared_runners_page", params: { expand_runners: true, anchor: 'js-runners-settings' } diff --git a/app/views/projects/runners/_specific_runners.html.haml b/app/views/projects/runners/_specific_runners.html.haml index 3634bacb6ec..f3a7037bdab 100644 --- a/app/views/projects/runners/_specific_runners.html.haml +++ b/app/views/projects/runners/_specific_runners.html.haml @@ -17,7 +17,7 @@ group_path: '' } - else = _('Please contact an admin to register runners.') - = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'prevent-users-from-registering-runners'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'restrict-runner-registration-by-all-users-in-an-instance'), target: '_blank', rel: 'noopener noreferrer' %hr diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml index 3a62c6f41cc..5f1dee39e25 100644 --- a/app/views/projects/settings/_general.html.haml +++ b/app/views/projects/settings/_general.html.haml @@ -1,5 +1,5 @@ - hidden_topics_field_id = 'project_topic_list_field' -= form_for [@project], html: { multipart: true, class: "edit-project js-general-settings-form" }, authenticity_token: true do |f| += gitlab_ui_form_for [@project], html: { multipart: true, class: "edit-project js-general-settings-form" }, authenticity_token: true do |f| %input{ name: 'update_section', type: 'hidden', value: 'js-general-settings' } %fieldset @@ -39,4 +39,4 @@ %hr = link_to _('Remove avatar'), project_avatar_path(@project), aria: { label: _('Remove avatar') }, data: { confirm: _('Avatar will be removed. Are you sure?'), 'confirm-btn-variant': 'danger' }, method: :delete, class: 'gl-button btn btn-danger-secondary' - = f.submit _('Save changes'), class: "gl-button btn btn-confirm gl-mt-6", data: { qa_selector: 'save_naming_topics_avatar_button' } + = f.submit _('Save changes'), pajamas_button: true, class: "gl-mt-6", data: { qa_selector: 'save_naming_topics_avatar_button' } diff --git a/app/views/projects/settings/branch_rules/index.html.haml b/app/views/projects/settings/branch_rules/index.html.haml index 571a992a552..80a41bb579b 100644 --- a/app/views/projects/settings/branch_rules/index.html.haml +++ b/app/views/projects/settings/branch_rules/index.html.haml @@ -3,4 +3,4 @@ %h3.gl-mb-5= s_('BranchRules|Branch rules details') -#js-branch-rules{ data: { project_path: @project.full_path, protected_branches_path: project_settings_repository_path(@project, anchor: 'js-protected-branches-settings'), approval_rules_path: project_settings_merge_requests_path(@project, anchor: 'js-merge-request-approval-settings'), status_checks_path: project_settings_merge_requests_path(@project, anchor: 'js-merge-request-settings') } } +#js-branch-rules{ data: { project_path: @project.full_path, protected_branches_path: project_settings_repository_path(@project, anchor: 'js-protected-branches-settings'), approval_rules_path: project_settings_merge_requests_path(@project, anchor: 'js-merge-request-approval-settings'), status_checks_path: project_settings_merge_requests_path(@project, anchor: 'js-merge-request-settings'), branches_path: project_branches_path(@project) } } diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml index 5748b4b0330..86238a41f0b 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -10,8 +10,8 @@ - base_domain_link_start = link_start % { url: base_domain_path } - help_link_continouos = link_to sprite_icon('question-o'), help_page_path('topics/autodevops/stages.md', anchor: 'auto-deploy'), target: '_blank', rel: 'noopener noreferrer' -- help_link_timed = link_to sprite_icon('question-o'), help_page_path('topics/autodevops/customize.md', anchor: 'timed-incremental-rollout-to-production'), target: '_blank', rel: 'noopener noreferrer' -- help_link_incremental = link_to sprite_icon('question-o'), help_page_path('topics/autodevops/customize.md', anchor: 'incremental-rollout-to-production'), target: '_blank', rel: 'noopener noreferrer' +- help_link_timed = link_to sprite_icon('question-o'), help_page_path('topics/autodevops/cicd_variables.md', anchor: 'timed-incremental-rollout-to-production'), target: '_blank', rel: 'noopener noreferrer' +- help_link_incremental = link_to sprite_icon('question-o'), help_page_path('topics/autodevops/cicd_variables.md', anchor: 'incremental-rollout-to-production'), target: '_blank', rel: 'noopener noreferrer' .row .col-lg-12 diff --git a/app/views/projects/settings/operations/_alert_management.html.haml b/app/views/projects/settings/operations/_alert_management.html.haml index d80f1e4597c..7433e81c11c 100644 --- a/app/views/projects/settings/operations/_alert_management.html.haml +++ b/app/views/projects/settings/operations/_alert_management.html.haml @@ -3,7 +3,7 @@ - add_page_specific_style 'page_bundles/alert_management_settings' - add_page_specific_style 'page_bundles/incident_management_list' -%section.settings.no-animate#js-alert-management-settings{ class: ('expanded' if expanded) } +%section.settings.no-animate#js-alert-management-settings{ class: ('expanded' if expanded), data: { qa_selector: 'alerts_settings_content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Alerts') diff --git a/app/views/projects/settings/repository/_protected_branches.html.haml b/app/views/projects/settings/repository/_protected_branches.html.haml index 31630828571..d2356b5df09 100644 --- a/app/views/projects/settings/repository/_protected_branches.html.haml +++ b/app/views/projects/settings/repository/_protected_branches.html.haml @@ -1,2 +1,2 @@ -= render "projects/protected_branches/index" += render "protected_branches/index" = render "projects/protected_tags/index" diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index 77c44b792ab..5fa70c3af32 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -15,7 +15,7 @@ = render "home_panel" -- if can?(current_user, :download_code, @project) && @project.repository_languages.present? +- if can?(current_user, :read_code, @project) && @project.repository_languages.present? - add_page_startup_graphql_call('repository/path_last_commit', { projectPath: @project.full_path, ref: current_ref, path: current_route_path || "" }) = repository_languages_bar(@project.repository_languages) @@ -25,8 +25,5 @@ - view_path = @project.default_view -- if show_auto_devops_callout?(@project) - = render 'shared/auto_devops_callout' - %div{ class: project_child_container_class(view_path) } = render view_path, is_project_overview: true diff --git a/app/views/projects/starrers/index.html.haml b/app/views/projects/starrers/index.html.haml index fe8a6508dd7..23578652862 100644 --- a/app/views/projects/starrers/index.html.haml +++ b/app/views/projects/starrers/index.html.haml @@ -1,4 +1,5 @@ - page_title _("Starrers") +- add_page_specific_style 'page_bundles/users' .top-area.adjust .nav-text diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index ed06c90efa8..2f8291d255f 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -2,7 +2,7 @@ - default_ref = params[:ref] || @project.default_branch - if @error - = render Pajamas::AlertComponent.new(variant: :danger, dismissible: true ) do |c| + = render Pajamas::AlertComponent.new(variant: :danger, dismissible: true) do |c| = c.body do = @error @@ -20,14 +20,9 @@ = label_tag :tag_name, nil = text_field_tag :tag_name, params[:tag_name], required: true, autofocus: true, class: 'form-control', data: { qa_selector: "tag_name_field" } .form-group.row - .col-sm-12.create-from + .col-sm-auto.create-from = label_tag :ref, 'Create from' - .dropdown - = 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 - = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') - = render 'shared/ref_dropdown', dropdown_class: 'wide' + .js-new-tag-ref-selector{ data: { project_id: @project.id, default_branch_name: default_ref, hidden_input_name: 'ref' } } .form-text.text-muted = s_('TagsPage|Existing branch name, tag, or commit SHA') .form-group.row @@ -42,5 +37,4 @@ = s_('TagsPage|Create tag') = render Pajamas::ButtonComponent.new(href: project_tags_path(@project)) do = s_('TagsPage|Cancel') --# haml-lint:disable InlineJavaScript -%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe + diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 29bdca1c876..fd807350245 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -2,7 +2,7 @@ .tree-ref-container.gl-display-flex.mb-2.mb-md-0 .tree-ref-holder - = render 'shared/ref_switcher', destination: 'tree', show_create: true + #js-tree-ref-switcher{ data: { project_id: @project.id, project_root_path: project_path(@project) } } #js-repo-breadcrumb{ data: breadcrumb_data_attributes } diff --git a/app/views/projects/triggers/_form.html.haml b/app/views/projects/triggers/_form.html.haml index b6b24a0c26a..b621f1ab3ed 100644 --- a/app/views/projects/triggers/_form.html.haml +++ b/app/views/projects/triggers/_form.html.haml @@ -6,6 +6,6 @@ %label.label-bold Token %p.form-control-plaintext= @trigger.token .form-group - = f.label :key, "Description", class: "label-bold" - = f.text_field :description, class: 'form-control gl-form-input', required: true, title: 'Trigger description is required.', placeholder: "Trigger description" + = f.label :key, s_("Trigger|Description"), class: "label-bold" + = f.text_field :description, class: 'form-control gl-form-input', required: true, title: 'Trigger description is required.', placeholder: s_("Trigger|Trigger description") = f.submit btn_text, pajamas_button: true diff --git a/app/views/protected_branches/_branches_list.html.haml b/app/views/protected_branches/_branches_list.html.haml new file mode 100644 index 00000000000..82eac348f16 --- /dev/null +++ b/app/views/protected_branches/_branches_list.html.haml @@ -0,0 +1,4 @@ +- can_admin_project = can?(current_user, :admin_project, @project) + += render layout: 'protected_branches/shared/branches_list', locals: { can_admin_project: can_admin_project } do + = render partial: 'protected_branches/protected_branch', collection: @protected_branches diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/protected_branches/_create_protected_branch.html.haml index 76aadc3be28..22a49ba9c7e 100644 --- a/app/views/projects/protected_branches/_create_protected_branch.html.haml +++ b/app/views/protected_branches/_create_protected_branch.html.haml @@ -11,4 +11,4 @@ dropdown_class: 'dropdown-menu-selectable capitalize-header', dropdown_qa_selector: 'allowed_to_push_dropdown_content' , dropdown_testid: 'allowed-to-push-dropdown', data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes', qa_selector: 'allowed_to_push_dropdown' }}) -= render 'projects/protected_branches/shared/create_protected_branch' += render 'protected_branches/shared/create_protected_branch' diff --git a/app/views/protected_branches/_index.html.haml b/app/views/protected_branches/_index.html.haml new file mode 100644 index 00000000000..4beca4845b8 --- /dev/null +++ b/app/views/protected_branches/_index.html.haml @@ -0,0 +1,7 @@ +- content_for :create_protected_branch do + = render 'protected_branches/create_protected_branch' + +- content_for :branches_list do + = render "protected_branches/branches_list" + += render 'protected_branches/shared/index' diff --git a/app/views/protected_branches/_protected_branch.html.haml b/app/views/protected_branches/_protected_branch.html.haml new file mode 100644 index 00000000000..423d7f23eb5 --- /dev/null +++ b/app/views/protected_branches/_protected_branch.html.haml @@ -0,0 +1,2 @@ += render layout: 'protected_branches/shared/protected_branch', locals: { protected_branch: protected_branch } do + = render_if_exists 'protected_branches/update_protected_branch', protected_branch: protected_branch diff --git a/app/views/protected_branches/_update_protected_branch.html.haml b/app/views/protected_branches/_update_protected_branch.html.haml new file mode 100644 index 00000000000..a9290d9e0da --- /dev/null +++ b/app/views/protected_branches/_update_protected_branch.html.haml @@ -0,0 +1 @@ += render 'protected_branches/shared/update_protected_branch', protected_branch: protected_branch diff --git a/app/views/projects/protected_branches/shared/_branches_list.html.haml b/app/views/protected_branches/shared/_branches_list.html.haml index 64db51d5df2..d041f9c5b48 100644 --- a/app/views/projects/protected_branches/shared/_branches_list.html.haml +++ b/app/views/protected_branches/shared/_branches_list.html.haml @@ -28,7 +28,7 @@ %span.has-tooltip{ data: { container: 'body' }, title: s_('ProtectedBranch|Allow all users with push access to force push.'), 'aria-hidden': 'true' } = sprite_icon('question', size: 16, css_class: 'gl-text-gray-500') - = render_if_exists 'projects/protected_branches/ee/code_owner_approval_table_head' + = render_if_exists 'protected_branches/ee/code_owner_approval_table_head' - if can_admin_project %th diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/protected_branches/shared/_create_protected_branch.html.haml index 770d79943b3..6b4a143df69 100644 --- a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml +++ b/app/views/protected_branches/shared/_create_protected_branch.html.haml @@ -8,7 +8,7 @@ .form-group.row = f.label :name, s_('ProtectedBranch|Branch:'), class: 'col-sm-12' .col-sm-12 - = render partial: "projects/protected_branches/shared/dropdown", locals: { f: f, toggle_classes: 'gl-w-full! gl-form-input-lg' } + = render partial: "protected_branches/shared/dropdown", locals: { f: f, toggle_classes: 'gl-w-full! gl-form-input-lg' } .form-text.text-muted - wildcards_url = help_page_url('user/project/protected_branches', anchor: 'configure-multiple-protected-branches-by-using-a-wildcard') - wildcards_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wildcards_url } @@ -30,6 +30,6 @@ - force_push_docs_url = help_page_url('topics/git/git_rebase', anchor: 'force-push') - force_push_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: force_push_docs_url } = (s_("ProtectedBranch|Allow all users with push access to %{tag_start}force push%{tag_end}.") % { tag_start: force_push_link_start, tag_end: '</a>' }).html_safe - = render_if_exists 'projects/protected_branches/ee/code_owner_approval_form', f: f + = render_if_exists 'protected_branches/ee/code_owner_approval_form', f: f - c.footer do = f.submit s_('ProtectedBranch|Protect'), disabled: true, data: { qa_selector: 'protect_button' }, pajamas_button: true diff --git a/app/views/projects/protected_branches/shared/_dropdown.html.haml b/app/views/protected_branches/shared/_dropdown.html.haml index c5dbf8991cd..c5dbf8991cd 100644 --- a/app/views/projects/protected_branches/shared/_dropdown.html.haml +++ b/app/views/protected_branches/shared/_dropdown.html.haml diff --git a/app/views/projects/protected_branches/shared/_index.html.haml b/app/views/protected_branches/shared/_index.html.haml index c204508d355..c204508d355 100644 --- a/app/views/projects/protected_branches/shared/_index.html.haml +++ b/app/views/protected_branches/shared/_index.html.haml diff --git a/app/views/projects/protected_branches/shared/_matching_branch.html.haml b/app/views/protected_branches/shared/_matching_branch.html.haml index 1a2ec38fae9..1a2ec38fae9 100644 --- a/app/views/projects/protected_branches/shared/_matching_branch.html.haml +++ b/app/views/protected_branches/shared/_matching_branch.html.haml diff --git a/app/views/projects/protected_branches/shared/_protected_branch.html.haml b/app/views/protected_branches/shared/_protected_branch.html.haml index 098bd4a7eeb..5dea85aaa41 100644 --- a/app/views/projects/protected_branches/shared/_protected_branch.html.haml +++ b/app/views/protected_branches/shared/_protected_branch.html.haml @@ -16,7 +16,7 @@ = yield - = render_if_exists 'projects/protected_branches/ee/code_owner_approval_table', protected_branch: protected_branch + = render_if_exists 'protected_branches/ee/code_owner_approval_table', protected_branch: protected_branch - if can_admin_project %td diff --git a/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml b/app/views/protected_branches/shared/_update_protected_branch.html.haml index d10196a83cc..0244f9e2158 100644 --- a/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml +++ b/app/views/protected_branches/shared/_update_protected_branch.html.haml @@ -9,7 +9,7 @@ %td.merge_access_levels-container = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", merge_access_levels.first&.access_level - = dropdown_tag( (merge_access_levels.first&.humanize || 'Select') , + = dropdown_tag((merge_access_levels.first&.humanize || 'Select') , options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container capitalize-header', data: { field_name: "allowed_to_merge_#{protected_branch.id}", preselected_items: access_levels_data(merge_access_levels) }}) - if user_merge_access_levels.any? @@ -22,7 +22,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') , + = dropdown_tag((push_access_levels.first&.humanize || 'Select') , options: { toggle_class: "js-allowed-to-push js-multiselect", 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? diff --git a/app/views/projects/protected_branches/show.html.haml b/app/views/protected_branches/show.html.haml index c671757a603..e0bd392ae93 100644 --- a/app/views/projects/protected_branches/show.html.haml +++ b/app/views/protected_branches/show.html.haml @@ -19,7 +19,7 @@ %th Last commit %tbody - @matching_refs.each do |matching_branch| - = render partial: "projects/protected_branches/shared/matching_branch", object: matching_branch + = render partial: "protected_branches/shared/matching_branch", object: matching_branch - else %p.settings-message.text-center Couldn't find any matching branches. diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb index 557a39ee157..c5403caeafa 100644 --- a/app/views/pwa/manifest.json.erb +++ b/app/views/pwa/manifest.json.erb @@ -1,7 +1,7 @@ { - "name": "GitLab", - "short_name": "GitLab", - "description": "<%= _("The complete DevOps platform. One application with endless possibilities. Organizations rely on GitLab’s source code management, CI/CD, security, and more to deliver software rapidly.") %>", + "name": "<%= Appearance.current&.title.presence || _('GitLab') %>", + "short_name": "<%= Appearance.current&.short_title.presence || _('GitLab') %>", + "description": "<%= Appearance.current&.description.presence || _("The complete DevOps platform. One application with endless possibilities. Organizations rely on GitLab’s source code management, CI/CD, security, and more to deliver software rapidly.") %>", "start_url": "<%= explore_projects_path %>", "scope": "<%= root_path %>", "display": "browser", diff --git a/app/views/registrations/welcome/show.html.haml b/app/views/registrations/welcome/show.html.haml index 283659875ef..f4e9a597fe2 100644 --- a/app/views/registrations/welcome/show.html.haml +++ b/app/views/registrations/welcome/show.html.haml @@ -18,22 +18,24 @@ %p.gl-text-center= html_escape(_('%{gitlab_experience_text}. Don\'t worry, this information isn\'t shared outside of your self-managed GitLab instance.')) % { gitlab_experience_text: gitlab_experience_text } = gitlab_ui_form_for(current_user, url: users_sign_up_welcome_path(glm_tracking_params), - html: { class: 'card gl-w-full! gl-p-5 js-users-signup-welcome', + html: { class: 'gl-w-full! gl-p-5 js-users-signup-welcome', 'aria-live' => 'assertive', data: { testid: 'welcome-form' } }) do |f| - .devise-errors - = render 'devise/shared/error_messages', resource: current_user - .row - .form-group.col-sm-12 - = f.label :role, _('Role'), class: 'label-bold' - = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, { include_blank: _('Select a role') }, class: 'form-control js-user-role-dropdown', autofocus: true, required: true, data: { qa_selector: 'role_dropdown' } - = render_if_exists "registrations/welcome/jobs_to_be_done", f: f - = render_if_exists "registrations/welcome/setup_for_company", f: f - = render_if_exists "registrations/welcome/joining_project" - = render 'devise/shared/email_opted_in', 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-confirm gl-button btn btn-block gl-mb-0 gl-p-3', data: { qa_selector: 'get_started_button' } + = render Pajamas::CardComponent.new do |c| + - c.body do + .devise-errors + = render 'devise/shared/error_messages', resource: current_user + .row + .form-group.col-sm-12 + = f.label :role, _('Role'), class: 'label-bold' + = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, { include_blank: _('Select a role') }, class: 'form-control js-user-role-dropdown', autofocus: true, required: true, data: { qa_selector: 'role_dropdown' } + = render_if_exists "registrations/welcome/jobs_to_be_done", f: f + = render_if_exists "registrations/welcome/setup_for_company", f: f + = render_if_exists "registrations/welcome/joining_project" + = render 'devise/shared/email_opted_in', 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-confirm gl-button btn btn-block gl-mb-0 gl-p-3', data: { qa_selector: 'get_started_button' } diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml index c15afd7bd5b..3e483fe8cd2 100644 --- a/app/views/search/_category.html.haml +++ b/app/views/search/_category.html.haml @@ -23,7 +23,7 @@ = search_filter_link 'milestones', _("Milestones") = users - - elsif @show_snippets + - elsif @search_service.show_snippets? = search_filter_link 'snippet_titles', _("Titles and Descriptions"), search: { snippets: true, group_id: nil, project_id: nil } - else = search_filter_link 'projects', _("Projects"), data: { qa_selector: 'projects_tab' } diff --git a/app/views/search/results/_issuable.html.haml b/app/views/search/results/_issuable.html.haml index 36458a909fc..188ead4008e 100644 --- a/app/views/search/results/_issuable.html.haml +++ b/app/views/search/results/_issuable.html.haml @@ -13,7 +13,7 @@ = highlight_and_truncate_issuable(issuable, @search_term, @search_highlight) .col-sm-3.gl-mt-3.gl-sm-mt-0.gl-text-right - if issuable.respond_to?(:upvotes_count) && issuable.upvotes_count > 0 - %li.issuable-upvotes.gl-list-style-none + %li.gl-list-style-none %span.has-tooltip{ title: _('Upvotes') } = sprite_icon('thumb-up', css_class: "gl-vertical-align-middle") = issuable.upvotes_count diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml index 9d812e77ad4..e1efa271d57 100644 --- a/app/views/search/show.html.haml +++ b/app/views/search/show.html.haml @@ -9,7 +9,7 @@ - project_attributes = @project&.attributes&.slice('id', 'namespace_id', 'name')&.merge(name_with_namespace: @project&.name_with_namespace) - if @search_results - - if @without_count + - if @search_service.without_count? - page_description(_("%{scope} results for term '%{term}'") % { scope: @scope, term: @search_term }) - else - page_description(_("%{count} %{scope} for term '%{term}'") % { count: @search_results.formatted_count(@scope), scope: @scope, term: @search_term }) @@ -20,7 +20,7 @@ = render_if_exists 'search/form_elasticsearch', attrs: { class: 'mb-2 mb-sm-0 align-self-center' } .gl-mt-3 - #js-search-topbar{ data: { "group-initial-data": group_attributes.to_json, "project-initial-data": project_attributes.to_json } } + #js-search-topbar{ data: { "group-initial-json": group_attributes.to_json, "project-initial-json": project_attributes.to_json, "elasticsearch-enabled": @elasticsearch_in_use.to_s, "default-branch-name": @project&.default_branch } } - if @search_term - if Feature.disabled?(:search_page_vertical_nav, current_user) = render 'search/category' diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml index c2b941c6106..93f919f01d9 100644 --- a/app/views/shared/_auto_devops_callout.html.haml +++ b/app/views/shared/_auto_devops_callout.html.haml @@ -1,13 +1,16 @@ -= render Pajamas::BannerComponent.new(button_text: s_('AutoDevOps|Enable in settings'), - button_link: project_settings_ci_cd_path(@project, anchor: 'autodevops-settings'), - svg_path: 'illustrations/autodevops.svg', - banner_options: { class: 'js-autodevops-banner', data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } }, - close_options: { 'aria-label' => s_('AutoDevOps|Dismiss Auto DevOps box'), class: 'js-close-callout' }) do |c| - - c.title do - = s_('AutoDevOps|Auto DevOps') +- container = @no_breadcrumb_container ? 'container-fluid' : container_class - %p= s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.') +%div{ class: [container, @content_class, 'gl-pt-5!'] } + = render Pajamas::BannerComponent.new(button_text: s_('AutoDevOps|Enable in settings'), + button_link: project_settings_ci_cd_path(@project, anchor: 'autodevops-settings'), + svg_path: 'illustrations/autodevops.svg', + banner_options: { class: 'js-autodevops-banner auto-devops-callout', data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } }, + close_options: { 'aria-label' => s_('AutoDevOps|Dismiss Auto DevOps box'), class: 'js-close-callout' }) do |c| + - c.title do + = s_('AutoDevOps|Auto DevOps') - %p - - link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer') - = s_('AutoDevOps|Learn more in the %{link_to_documentation}').html_safe % { link_to_documentation: link } + %p= s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.') + + %p + - link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer') + = s_('AutoDevOps|Learn more in the %{link_to_documentation}').html_safe % { link_to_documentation: link } diff --git a/app/views/shared/_file_highlight.html.haml b/app/views/shared/_file_highlight.html.haml index 73ace033dc6..a749d1037a1 100644 --- a/app/views/shared/_file_highlight.html.haml +++ b/app/views/shared/_file_highlight.html.haml @@ -1,16 +1,28 @@ +-# We're not using `link_to` in the line loop because it is too slow once we get to thousands of lines. + +- offset = defined?(first_line_number) ? first_line_number : 1 +- highlight = defined?(highlight_line) && highlight_line ? highlight_line - offset : nil +- file_line_blame = Feature.enabled?(:file_line_blame) + +- if file_line_blame + - line_class = "js-line-links" + - blame_path = project_blame_path(@project, tree_join(@ref, blob.path)) +- else + - line_class = nil + - blame_path = nil + +- highlighted_blob = blob.present.highlight + #blob-content.file-content.code.js-syntax-highlight - - offset = defined?(first_line_number) ? first_line_number : 1 - - if Feature.enabled?(:file_line_blame) - - blame_path = project_blame_path(@project, tree_join(@ref, blob.path)) .line-numbers{ class: "gl-px-0!", data: { blame_path: blame_path } } - if blob.data.present? - - blob.data.each_line.each_with_index do |_, index| + - highlighted_blob.lines.count.times do |index| - i = index + offset - -# We're not using `link_to` because it is too slow once we get to thousands of lines. - %a.file-line-num.diff-line-num{ class: ("js-line-links" if Feature.enabled?(:file_line_blame)), href: "#L#{i}", id: "L#{i}", 'data-line-number' => i } + + %a.file-line-num.diff-line-num{ class: line_class, href: "#L#{i}", id: "L#{i}", 'data-line-number' => i } = i - - highlight = defined?(highlight_line) && highlight_line ? highlight_line - offset : nil + .blob-content{ data: { blob_id: blob.id, path: blob.path, highlight_line: highlight, qa_selector: 'file_content' } } %pre.code.highlight %code - = blob.present.highlight + = highlighted_blob diff --git a/app/views/shared/_ide_root.html.haml b/app/views/shared/_ide_root.html.haml new file mode 100644 index 00000000000..848ff1e5728 --- /dev/null +++ b/app/views/shared/_ide_root.html.haml @@ -0,0 +1,11 @@ +- data = local_assigns.fetch(:data) +- loading_text = local_assigns.fetch(:loading_text) + +-# Fix for iOS 13+, the height of the page is actually less than +-# 100vh because of the presence of the bottom bar +- @body_class = 'gl-max-h-full gl-fixed' + +#ide.gl--flex-center.gl-h-full{ data: data } + .gl-text-center + = gl_loading_icon(size: 'md') + %h2.clgray= loading_text diff --git a/app/views/shared/_issuable_meta_data.html.haml b/app/views/shared/_issuable_meta_data.html.haml index 01ab7bf9cd4..982d3b68792 100644 --- a/app/views/shared/_issuable_meta_data.html.haml +++ b/app/views/shared/_issuable_meta_data.html.haml @@ -6,23 +6,23 @@ - issuable_mr = @issuable_meta_data[issuable.id].merge_requests_count - if issuable_mr > 0 - %li.issuable-mr.gl-display-none.gl-sm-display-block.has-tooltip{ title: _('Related merge requests'), data: { testid: 'merge-requests' } } + %li.gl-display-none.gl-sm-display-block.has-tooltip{ title: _('Related merge requests'), data: { testid: 'merge-requests' } } = sprite_icon('merge-request', css_class: "gl-vertical-align-middle") = issuable_mr - if upvotes > 0 - %li.issuable-upvotes.gl-display-none.gl-sm-display-block.has-tooltip{ title: _('Upvotes') } + %li.gl-display-none.gl-sm-display-block.has-tooltip{ title: _('Upvotes'), data: { testid: 'issuable-upvotes' } } = sprite_icon('thumb-up', css_class: "gl-vertical-align-middle") = upvotes - if downvotes > 0 - %li.issuable-downvotes.gl-display-none.gl-sm-display-block.has-tooltip{ title: _('Downvotes') } + %li.gl-display-none.gl-sm-display-block.has-tooltip{ title: _('Downvotes'), data: { testid: 'issuable-downvotes' } } = sprite_icon('thumb-down', css_class: "gl-vertical-align-middle") = downvotes = render_if_exists 'shared/issuable/blocking_issues_count', issuable: issuable -%li.issuable-comments.gl-display-none.gl-sm-display-block - = link_to issuable_path, class: ['has-tooltip', ('no-comments' if note_count == 0)], title: _('Comments') do +%li.gl-display-none.gl-sm-display-block + = link_to issuable_path, class: ['has-tooltip', ('no-comments' if note_count == 0)], title: _('Comments'), data: { testid: 'issuable-comments' } do = sprite_icon('comments', css_class: 'gl-vertical-align-text-bottom') = note_count diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index 1645c2695b5..8a626f1620b 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -32,17 +32,17 @@ - if label.project_label? && label.project.group && can?(current_user, :admin_label, label.project.group) %li = render Pajamas::ButtonComponent.new(category: :tertiary, - button_options: { class: 'js-promote-project-label-button', data: { url: promote_project_label_path(label.project, label), label_title: label.title, label_color: label.color, label_text_color: label.text_color, group_name: label.project.group.name } } ) do + button_options: { class: 'js-promote-project-label-button', data: { url: promote_project_label_path(label.project, label), label_title: label.title, label_color: label.color, label_text_color: label.text_color, group_name: label.project.group.name } }) do = _('Promote to group label') %li %span = render Pajamas::ButtonComponent.new(category: :tertiary, - button_options: { class: 'text-danger js-delete-label-modal-button', data: { label_name: label.name, subject_name: label.subject_name, destroy_path: label.destroy_path } } ) do + button_options: { class: 'text-danger js-delete-label-modal-button', data: { label_name: label.name, subject_name: label.subject_name, destroy_path: label.destroy_path } }) do = _('Delete') - if current_user %li.gl-display-inline-block.label-subscription.js-label-subscription.gl-ml-3 - if label.can_subscribe_to_label_in_different_levels? - = render Pajamas::ButtonComponent.new(button_options: { class: "js-unsubscribe-button #{'hidden' if status.unsubscribed?}", data: { url: toggle_subscription_path, toggle: 'tooltip', container: 'body' }, title: tooltip_title } ) do + = render Pajamas::ButtonComponent.new(button_options: { class: "js-unsubscribe-button #{'hidden' if status.unsubscribed?}", data: { url: toggle_subscription_path, toggle: 'tooltip', container: 'body' }, title: tooltip_title }) do = _('Unsubscribe') .dropdown.dropdown-group-label{ class: ('hidden' unless status.unsubscribed?) } = render Pajamas::ButtonComponent.new(button_options: { class: 'gl-w-full', data: { toggle: 'dropdown' } }) do @@ -51,11 +51,11 @@ .dropdown-menu.dropdown-open-left %ul %li - = render Pajamas::ButtonComponent.new(category: :tertiary, button_options: { class: "js-subscribe-button #{'hidden' unless status.unsubscribed?}", data: { status: status, url: toggle_subscription_project_label_path(@project, label) } } ) do + = render Pajamas::ButtonComponent.new(category: :tertiary, button_options: { class: "js-subscribe-button #{'hidden' unless status.unsubscribed?}", data: { status: status, url: toggle_subscription_project_label_path(@project, label) } }) do = _('Subscribe at project level') %li - = render Pajamas::ButtonComponent.new(category: :tertiary, button_options: { class: "js-subscribe-button js-group-level #{'hidden' unless status.unsubscribed?}", data: { status: status, url: toggle_subscription_group_label_path(label.group, label) } } ) do + = render Pajamas::ButtonComponent.new(category: :tertiary, button_options: { class: "js-subscribe-button js-group-level #{'hidden' unless status.unsubscribed?}", data: { status: status, url: toggle_subscription_group_label_path(label.group, label) } }) do = _('Subscribe at group level') - else - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-subscribe-button gl-w-full', data: { status: status, url: toggle_subscription_path, toggle: 'tooltip', container: 'body' }, title: tooltip_title } ) do + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-subscribe-button gl-w-full', data: { status: status, url: toggle_subscription_path, toggle: 'tooltip', container: 'body' }, title: tooltip_title }) do = label_subscription_toggle_button_text(label, @project) diff --git a/app/views/shared/_milestones_filter.html.haml b/app/views/shared/_milestones_filter.html.haml index ef41dc9bb79..0053f2fe444 100644 --- a/app/views/shared/_milestones_filter.html.haml +++ b/app/views/shared/_milestones_filter.html.haml @@ -1,6 +1,6 @@ - count_badge_classes = 'gl-display-none gl-sm-display-inline-flex' -= gl_tabs_nav( {class: 'gl-border-b-0 gl-flex-grow-1', data: { testid: 'milestones-filter' } } ) do += gl_tabs_nav({class: 'gl-border-b-0 gl-flex-grow-1', data: { testid: 'milestones-filter' } }) do = gl_tab_link_to milestones_filter_path(state: 'opened'), { item_active: params[:state].blank? || params[:state] == 'opened' } do = _('Open') = gl_tab_counter_badge counts[:opened], { class: count_badge_classes } diff --git a/app/views/shared/_new_project_item_select.html.haml b/app/views/shared/_new_project_item_select.html.haml index 0bd5d1795d0..d080d8be8fe 100644 --- a/app/views/shared/_new_project_item_select.html.haml +++ b/app/views/shared/_new_project_item_select.html.haml @@ -1,5 +1,5 @@ - if any_projects?(@projects) - .dropdown.b-dropdown.gl-new-dropdown.btn-group.project-item-select-holder{ class: 'gl-display-inline-flex!' } + .dropdown.b-dropdown.gl-dropdown.btn-group.project-item-select-holder{ class: 'gl-display-inline-flex!' } %a.btn.gl-button.btn-confirm.split-content-button.js-new-project-item-link.block-truncated{ href: '', data: { label: local_assigns[:label], type: local_assigns[:type] } } = gl_loading_icon(inline: true, color: 'light') = project_select_tag :project_path, class: "project-item-select gl-absolute! gl-visibility-hidden", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at', relative_path: local_assigns[:path], with_shared: local_assigns[:with_shared], include_projects_in_subgroups: local_assigns[:include_projects_in_subgroups] }, with_feature_enabled: local_assigns[:with_feature_enabled] diff --git a/app/views/shared/_ref_switcher.html.haml b/app/views/shared/_ref_switcher.html.haml index 20bf2141cc3..fa718a9c907 100644 --- a/app/views/shared/_ref_switcher.html.haml +++ b/app/views/shared/_ref_switcher.html.haml @@ -13,7 +13,7 @@ - @options && @options.each do |key, value| = hidden_field_tag key, value, id: nil .dropdown - = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: ref, refs_url: refs_project_path(@project, sort: 'updated_desc'), field_name: field_name, submit_form_on_click: true, visit: true, qa_selector: "branches_dropdown", testid: "branches-select" }, { toggle_class: "js-project-refs-dropdown" } + = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: ref, ref_type: @ref_type, refs_url: refs_project_path(@project, sort: 'updated_desc'), field_name: field_name, submit_form_on_click: true, visit: true, qa_selector: "branches_dropdown", testid: "branches-select" }, { toggle_class: "js-project-refs-dropdown" } .dropdown-menu.dropdown-menu-selectable.git-revision-dropdown.dropdown-menu-paging{ class: ("dropdown-menu-right" if local_assigns[:align_right]), data: { qa_selector: "branches_dropdown_content" } } .dropdown-page-one = dropdown_title _("Switch branch/tag") diff --git a/app/views/shared/_web_ide_button.html.haml b/app/views/shared/_web_ide_button.html.haml index 83646a3c92e..aeaccdfa54b 100644 --- a/app/views/shared/_web_ide_button.html.haml +++ b/app/views/shared/_web_ide_button.html.haml @@ -2,4 +2,4 @@ - button_data = web_ide_button_data({ blob: blob }) - fork_options = fork_modal_options(@project, @ref, @path, blob) -.gl-display-inline-block{ data: { options: button_data.merge(fork_options).to_json }, id: "js-#{type}-web-ide-link" } +.gl-display-inline-block{ data: { options: button_data.merge(fork_options).to_json, web_ide_promo_popover_img: image_path('web-ide-promo-popover.svg') }, id: "js-#{type}-web-ide-link" } diff --git a/app/views/shared/builds/_tabs.html.haml b/app/views/shared/builds/_tabs.html.haml index 8e4b8d6d428..8f2b9fc06e3 100644 --- a/app/views/shared/builds/_tabs.html.haml +++ b/app/views/shared/builds/_tabs.html.haml @@ -1,6 +1,6 @@ - count_badge_classes = 'gl-display-none gl-sm-display-inline-flex' -= gl_tabs_nav( {class: 'scrolling-tabs nav-links gl-display-flex gl-flex-grow-1 gl-w-full nav gl-border-b-0', data: { testid: 'jobs-tabs' } } ) do += gl_tabs_nav({class: 'scrolling-tabs nav-links gl-display-flex gl-flex-grow-1 gl-w-full nav gl-border-b-0', data: { testid: 'jobs-tabs' } }) do = gl_tab_link_to build_path_proc.call(nil), { item_active: scope.nil? } do = _('All') = gl_tab_counter_badge(limited_counter_with_delimiter(all_builds), { class: count_badge_classes }) diff --git a/app/views/shared/empty_states/_milestones.html.haml b/app/views/shared/empty_states/_milestones.html.haml index fb69e75370e..0d7dbd1415b 100644 --- a/app/views/shared/empty_states/_milestones.html.haml +++ b/app/views/shared/empty_states/_milestones.html.haml @@ -6,7 +6,7 @@ .svg-content = image_tag 'illustrations/milestone_burndown_chart.svg' .col-12 - .text-content + .text-content.text-center %h4= s_('Milestones|Use milestones to track issues and merge requests over a fixed period of time') %p.state-description = s_('Milestones|Organize issues and merge requests into a cohesive group, and set optional start and due dates. %{learn_more_link}').html_safe % { learn_more_link: learn_more_link } diff --git a/app/views/shared/empty_states/_milestones_tab.html.haml b/app/views/shared/empty_states/_milestones_tab.html.haml index f6760b0a3f4..52df30434b4 100644 --- a/app/views/shared/empty_states/_milestones_tab.html.haml +++ b/app/views/shared/empty_states/_milestones_tab.html.haml @@ -12,6 +12,6 @@ %h4.text-center= s_('Milestones|There are no closed milestones') - else %h4.text-center= s_('Milestones|There are no open milestones') - %p.state-description + %p.state-description.text-center = s_('Milestones|Create a milestone to better track your issues and merge requests. %{learn_more_link}').html_safe % { learn_more_link: learn_more_link } = yield diff --git a/app/views/shared/file_hooks/_index.html.haml b/app/views/shared/file_hooks/_index.html.haml index d48e9f3d02e..16e89463a4b 100644 --- a/app/views/shared/file_hooks/_index.html.haml +++ b/app/views/shared/file_hooks/_index.html.haml @@ -11,15 +11,16 @@ .col-lg-8.gl-mb-3 - if file_hooks.any? - .card - .card-header + = render Pajamas::CardComponent.new do |c| + - c.header do = _('File Hooks (%{count})') % { count: file_hooks.count } - %ul.content-list - - file_hooks.each do |file| - %li - .monospace - = File.basename(file) - + - c.body do + %ul.content-list + - file_hooks.each do |file| + %li + .monospace + = File.basename(file) - else - .card.bg-light.text-center - .nothing-here-block= _('No file hooks found.') + = render Pajamas::CardComponent.new do |c| + - c.body do + .nothing-here-block= _('No file hooks found.') diff --git a/app/views/shared/gitlab_version/_security_patch_upgrade_alert.html.haml b/app/views/shared/gitlab_version/_security_patch_upgrade_alert.html.haml new file mode 100644 index 00000000000..9fe1400e877 --- /dev/null +++ b/app/views/shared/gitlab_version/_security_patch_upgrade_alert.html.haml @@ -0,0 +1,4 @@ +- return unless show_security_patch_upgrade_alert? + +#js-security-patch-upgrade-alert{ data: { "current_version": Gitlab.version_info } } +#js-security-patch-upgrade-alert-modal{ data: { "current_version": Gitlab.version_info, "version": gitlab_version_check.to_json } } diff --git a/app/views/shared/integrations/prometheus/_custom_metrics.html.haml b/app/views/shared/integrations/prometheus/_custom_metrics.html.haml index 896249c6163..dda84e0fb9e 100644 --- a/app/views/shared/integrations/prometheus/_custom_metrics.html.haml +++ b/app/views/shared/integrations/prometheus/_custom_metrics.html.haml @@ -6,12 +6,12 @@ = link_to s_('PrometheusService|More information'), help_page_path('operations/metrics/index.md', anchor: 'adding-custom-metrics'), target: '_blank', rel: "noopener noreferrer" .col-lg-9 - .card.custom-monitored-metrics.js-panel-custom-monitored-metrics{ data: { qa_selector: 'custom_metrics_container', active_custom_metrics: project_prometheus_metrics_path(project), environments_data: environments_list_data, service_active: "#{integration.active}" } } + .card.custom-monitored-metrics.js-panel-custom-monitored-metrics{ data: { active_custom_metrics: project_prometheus_metrics_path(project), environments_data: environments_list_data, service_active: "#{integration.active}" } } .card-header %strong = s_('PrometheusService|Custom metrics') = gl_badge_tag 0, nil, class: 'js-custom-monitored-count' - = link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'btn gl-button btn-confirm gl-ml-auto js-new-metric-button hidden', data: { qa_selector: 'new_metric_button' } + = link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'btn gl-button btn-confirm gl-ml-auto js-new-metric-button hidden' .card-body .flash-container.hidden .flash-warning diff --git a/app/views/shared/integrations/prometheus/_metrics.html.haml b/app/views/shared/integrations/prometheus/_metrics.html.haml index 8ee0ddfa1b1..c74dbfd8b15 100644 --- a/app/views/shared/integrations/prometheus/_metrics.html.haml +++ b/app/views/shared/integrations/prometheus/_metrics.html.haml @@ -25,8 +25,8 @@ .card.hidden.js-panel-missing-env-vars .card-header - = sprite_icon('chevron-lg-right', css_class: 'panel-toggle js-panel-toggle-right' ) - = sprite_icon('chevron-lg-down', css_class: 'panel-toggle js-panel-toggle-down hidden' ) + = sprite_icon('chevron-lg-right', css_class: 'panel-toggle js-panel-toggle-right') + = sprite_icon('chevron-lg-down', css_class: 'panel-toggle js-panel-toggle-down hidden') = s_('PrometheusService|Missing environment variable') = gl_badge_tag 0, nil, class: 'js-env-var-count' .card-body.hidden diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index a325ad5f447..07cdbbece8c 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -62,9 +62,9 @@ = sanitize(html_escape(_('Please review the %{linkStart}contribution guidelines%{linkEnd} for this project.')) % { linkStart: contribution_guidelines_start, linkEnd: contribution_guidelines_end }) - if issuable.new_record? - = form.submit "#{_('Create')} #{issuable.class.model_name.human.downcase}", class: 'gl-button btn btn-confirm gl-mr-2', data: { qa_selector: 'issuable_create_button', track_action: 'click_button', track_label: 'submit_mr', track_value: 0 } + = form.submit "#{_('Create')} #{issuable.class.model_name.human.downcase}", pajamas_button: true, class: 'gl-mr-2', data: { qa_selector: 'issuable_create_button', track_action: 'click_button', track_label: 'submit_mr', track_value: 0 } - else - = form.submit _('Save changes'), class: 'gl-button btn btn-confirm gl-mr-2', data: { track_action: 'click_button', track_label: 'submit_mr', track_value: 0 } + = form.submit _('Save changes'), pajamas_button: true, class: 'gl-mr-2', data: { track_action: 'click_button', track_label: 'submit_mr', track_value: 0 } - if issuable.new_record? = link_to _('Cancel'), polymorphic_path([@project, issuable.class]), class: 'btn gl-button btn-default js-reset-autosave' diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 0fd128df997..39a123f4775 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -20,7 +20,7 @@ .js-sidebar-todo-widget-root{ data: { project_path: issuable_sidebar[:project_full_path], iid: issuable_sidebar[:iid], id: issuable_sidebar[:id] } } = form_for issuable_type, url: issuable_sidebar[:issuable_json_path], remote: true, html: { class: 'issuable-context-form inline-update js-issuable-update' } do |f| - .block.assignee{ class: "#{'gl-mt-3' if !signed_in && moved_sidebar_enabled}", data: { qa_selector: 'assignee_block_container' } } + .block.assignee{ class: "#{'gl-mt-3' if !signed_in && moved_sidebar_enabled}", data: { qa_selector: 'assignee_block_container', testid: 'assignee-block-container' } } = render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees, signed_in: signed_in - if issuable_sidebar[:supports_severity] @@ -101,7 +101,7 @@ .sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body', boundary: 'viewport' }, title: _('Move issue') } = sprite_icon('long-arrow') .dropdown.sidebar-move-issue-dropdown.hide-collapsed - = render Pajamas::ButtonComponent.new(block: true, button_options: { class: 'js-sidebar-dropdown-toggle js-move-issue', data: { toggle: 'dropdown', display: 'static', track_label: "right_sidebar", track_property: "move_issue", track_action: "click_button", track_value: "" } } ) do + = render Pajamas::ButtonComponent.new(block: true, button_options: { class: 'js-sidebar-dropdown-toggle js-move-issue', data: { toggle: 'dropdown', display: 'static', track_label: "right_sidebar", track_property: "move_issue", track_action: "click_button", track_value: "" } }) do = _('Move issue') .dropdown-menu.dropdown-menu-selectable.dropdown-extended-height = dropdown_title(_('Move issue')) diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml index 51f49c7ca8e..0f6ef33d532 100644 --- a/app/views/shared/issuable/form/_title.html.haml +++ b/app/views/shared/issuable/form/_title.html.haml @@ -4,8 +4,8 @@ - no_issuable_templates = issuable_templates(ref_project, issuable.to_ability_name).empty? - toggle_wip_link_start = '<a href="" class="js-toggle-wip">' - toggle_wip_link_end = '</a>' -- add_wip_text = (_('%{link_start}Start the title with %{draft_snippet}%{link_end} to prevent a merge request draft from merging before it\'s ready.') % { link_start: toggle_wip_link_start, link_end: toggle_wip_link_end, draft_snippet: '<code>Draft:</code>'.html_safe } ).html_safe -- remove_wip_text = (_('%{link_start}Remove the %{draft_snippet} prefix%{link_end} from the title to allow this merge request to be merged when it\'s ready.' ) % { link_start: toggle_wip_link_start, link_end: toggle_wip_link_end, draft_snippet: '<code>Draft</code>'.html_safe } ).html_safe +- add_wip_text = (_('%{link_start}Start the title with %{draft_snippet}%{link_end} to prevent a merge request draft from merging before it\'s ready.') % { link_start: toggle_wip_link_start, link_end: toggle_wip_link_end, draft_snippet: '<code>Draft:</code>'.html_safe }).html_safe +- remove_wip_text = (_('%{link_start}Remove the %{draft_snippet} prefix%{link_end} from the title to allow this merge request to be merged when it\'s ready.') % { link_start: toggle_wip_link_start, link_end: toggle_wip_link_end, draft_snippet: '<code>Draft</code>'.html_safe }).html_safe %div{ data: { testid: 'issue-title-input-field' } } = form.text_field :title, required: true, aria: { required: true }, maxlength: 255, autofocus: true, diff --git a/app/views/shared/issue_type/_details_content.html.haml b/app/views/shared/issue_type/_details_content.html.haml index 8a9b71fd91e..42f6f7b71a3 100644 --- a/app/views/shared/issue_type/_details_content.html.haml +++ b/app/views/shared/issue_type/_details_content.html.haml @@ -29,7 +29,7 @@ - if can?(current_user, :admin_feature_flags_issue_links, @project) = render_if_exists 'projects/issues/related_feature_flags' - - if can?(current_user, :download_code, @project) + - if can?(current_user, :read_code, @project) - add_page_startup_api_call related_branches_path #related-branches{ data: { url: related_branches_path } } -# This element is filled in using JavaScript. diff --git a/app/views/shared/nav/_sidebar_submenu.html.haml b/app/views/shared/nav/_sidebar_submenu.html.haml index 344dafe7c0f..33b48470020 100644 --- a/app/views/shared/nav/_sidebar_submenu.html.haml +++ b/app/views/shared/nav/_sidebar_submenu.html.haml @@ -1,5 +1,5 @@ %ul.sidebar-sub-level-items{ class: ('is-fly-out-only' unless sidebar_menu.has_renderable_items?) } - = nav_link(**sidebar_menu.all_active_routes, html_options: { class: 'fly-out-top-item' } ) do + = nav_link(**sidebar_menu.all_active_routes, html_options: { class: 'fly-out-top-item' }) do %span.fly-out-top-item-container %strong.fly-out-top-item-name = sidebar_menu.title diff --git a/app/views/shared/projects/_dropdown.html.haml b/app/views/shared/projects/_dropdown.html.haml index 88ac03bf9e3..59f8bf0e875 100644 --- a/app/views/shared/projects/_dropdown.html.haml +++ b/app/views/shared/projects/_dropdown.html.haml @@ -1,5 +1,5 @@ - @sort ||= sort_value_latest_activity -.dropdown.js-project-filter-dropdown-wrap +.dropdown.js-project-filter-dropdown-wrap.gl-display-inline = dropdown_toggle(projects_sort_options_hash[@sort], { toggle: 'dropdown', display: 'static' }, { id: 'sort-projects-dropdown' }) %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable %li.dropdown-header diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 908eb2428e8..40cd81ab3da 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -52,7 +52,7 @@ %span.user-access-role.gl-display-block.gl-m-0{ data: { qa_selector: 'user_role_content' } }= Gitlab::Access.human_access(access) - if !explore_projects_tab? - = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: project + = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: project, additional_classes: 'gl-ml-3!' - if show_last_commit_as_description .description.gl-display-none.gl-sm-display-block.gl-overflow-hidden.gl-mr-3.gl-mt-2 diff --git a/app/views/shared/projects/_search_bar.html.haml b/app/views/shared/projects/_search_bar.html.haml deleted file mode 100644 index 5271a5fac09..00000000000 --- a/app/views/shared/projects/_search_bar.html.haml +++ /dev/null @@ -1,26 +0,0 @@ -- @sort ||= sort_value_latest_activity -- project_tab_filter = local_assigns.fetch(:project_tab_filter, "") -- flex_grow_and_shrink_xs = 'd-flex flex-xs-grow-1 flex-xs-shrink-1 flex-grow-0 flex-shrink-0' - -.filtered-search-block.row-content-block.bt-0 - .filtered-search-wrapper.d-flex.gl-flex-nowrap.flex-column.flex-sm-wrap.flex-sm-row.flex-xl-nowrap - - unless project_tab_filter == :starred - .filtered-search-nav.mb-2.mb-lg-0{ class: flex_grow_and_shrink_xs } - = render 'dashboard/projects/nav', project_tab_filter: project_tab_filter - .filtered-search.d-flex.flex-grow-1.flex-shrink-1.w-100.mb-2.mb-lg-0.ml-0{ class: project_tab_filter == :starred ? "extended-filtered-search-box mb-2 mb-lg-0" : "ml-sm-3" } - .btn-group.w-100{ role: "group" } - .btn-group.w-100{ role: "group" } - .filtered-search-box.m-0 - .filtered-search-box-input-container.pl-2 - = render 'shared/projects/search_form', admin_view: false, search_form_placeholder: _("Search projects...") - = render Pajamas::ButtonComponent.new(icon: 'search', icon_classes: 'search-icon', button_options: { type: 'submit', form: 'project-filter-form' }) - .filtered-search-dropdown.flex-row.align-items-center.mb-2.m-sm-0#filtered-search-visibility-dropdown{ class: flex_grow_and_shrink_xs } - .filtered-search-dropdown-label.p-0.pl-sm-3.font-weight-bold - %span - = _("Visibility") - = render 'explore/projects/filter', has_label: true - .filtered-search-dropdown.flex-row.align-items-center.m-sm-0#filtered-search-sorting-dropdown{ class: flex_grow_and_shrink_xs } - .filtered-search-dropdown-label.p-0.pl-sm-3.font-weight-bold - %span - = _("Sort by") - = render 'shared/projects/sort_dropdown' diff --git a/app/views/shared/projects/_search_form.html.haml b/app/views/shared/projects/_search_form.html.haml index e598343d698..07a6d5bec78 100644 --- a/app/views/shared/projects/_search_form.html.haml +++ b/app/views/shared/projects/_search_form.html.haml @@ -1,10 +1,9 @@ -- form_field_classes = local_assigns[:admin_view] || !Feature.enabled?(:project_list_filter_bar) ? 'input-short js-projects-list-filter' : 'gl-w-full! gl-pl-7 ' - placeholder = local_assigns[:search_form_placeholder] ? search_form_placeholder : _('Filter by name') = form_tag filter_projects_path, method: :get, class: 'project-filter-form', data: { qa_selector: 'project_filter_form_container' }, id: 'project-filter-form' do |f| = search_field_tag :name, params[:name], placeholder: placeholder, - class: "project-filter-form-field form-control #{form_field_classes}", + class: "project-filter-form-field form-control input-short js-projects-list-filter", spellcheck: false, id: 'project-filter-form-field', autofocus: local_assigns[:autofocus] @@ -24,4 +23,22 @@ - if params[:visibility_level].present? = hidden_field_tag :visibility_level, params[:visibility_level] + - if params[:language].present? + = hidden_field_tag :language, params[:language] + + - if Feature.enabled?(:project_language_search, current_user) + .dropdown.inline + = dropdown_toggle(search_language_placeholder, { toggle: 'dropdown', testid: 'project-language-dropdown' }) + %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable + %li + = link_to _('Any'), filter_projects_path(language: nil) + - programming_languages.each do |language| + %li + = link_to filter_projects_path(language: language.id), class: language_state_class(language) do + = language.name + + = submit_tag nil, class: 'gl-display-none!' + + = render 'shared/projects/dropdown' + = render_if_exists 'shared/projects/search_fields' diff --git a/app/views/shared/projects/_sort_dropdown.html.haml b/app/views/shared/projects/_sort_dropdown.html.haml deleted file mode 100644 index f3aeaacbdb1..00000000000 --- a/app/views/shared/projects/_sort_dropdown.html.haml +++ /dev/null @@ -1,39 +0,0 @@ -- @sort ||= sort_value_latest_activity -- toggle_text = projects_sort_option_titles[@sort] - -.btn-group.w-100{ role: "group" } - .btn-group.w-100.dropdown.js-project-filter-dropdown-wrap{ role: "group" } - %button#sort-projects-dropdown.gl-button.btn.btn-default.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } } - = toggle_text - = 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") - - projects_sort_options_hash.each do |value, title| - %li - = link_to title, filter_projects_path(sort: value), class: ("is-active" if toggle_text == title) - - %li.divider - %li - = link_to filter_projects_path(archived: nil), class: ("is-active" unless params[:archived].present?) do - = _("Hide archived projects") - %li - = link_to filter_projects_path(archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do - = _("Show archived projects") - %li - = link_to filter_projects_path(archived: 'only'), class: ("is-active" if params[:archived] == 'only') do - = _("Show archived projects only") - - - if current_user && @group && @group.shared_projects.present? - %li.divider - %li - = link_to filter_projects_path(shared: nil), class: ("is-active" unless params[:shared].present?) do - = _("All projects") - %li - = link_to filter_projects_path(shared: 0), class: ("is-active" if params[:shared] == '0') do - = _("Hide shared projects") - %li - = link_to filter_projects_path(shared: 1), class: ("is-active" if params[:shared] == '1') do - = _("Hide group projects") - - = project_sort_direction_button(@sort) diff --git a/app/views/shared/runners/_form.html.haml b/app/views/shared/runners/_form.html.haml index 024b06fe97a..f4b6c3c3a50 100644 --- a/app/views/shared/runners/_form.html.haml +++ b/app/views/shared/runners/_form.html.haml @@ -51,4 +51,4 @@ .col-sm-10 = f.text_field :private_projects_minutes_cost_factor, class: 'form-control' .form-actions - = f.submit _('Save changes'), class: 'gl-button btn btn-confirm' + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/shared/ssh_keys/_key_delete.html.haml b/app/views/shared/ssh_keys/_key_delete.html.haml index f8bb0e21f67..4b89b2a0cbf 100644 --- a/app/views/shared/ssh_keys/_key_delete.html.haml +++ b/app/views/shared/ssh_keys/_key_delete.html.haml @@ -1,9 +1,7 @@ -- title = _('Delete Key') -- aria = { label: title } +- icon = local_assigns[:icon] +- category = local_assigns[:category] || :primary -- if defined?(text) - = button_to text, '#', class: html_class, data: button_data, title: title, aria: aria -- else - = button_to '#', class: html_class, data: button_data, title: title, aria: aria do - %span.sr-only= _('Delete') - = sprite_icon('remove') +.gl-p-2 + = render Pajamas::ButtonComponent.new(variant: :danger, category: category, icon: ('remove' if icon), button_options: { class: 'js-confirm-modal-button', data: button_data }) do + - unless icon + = _('Delete') diff --git a/app/views/shared/topics/_search_form.html.haml b/app/views/shared/topics/_search_form.html.haml index 97343983b3c..2806b2865dd 100644 --- a/app/views/shared/topics/_search_form.html.haml +++ b/app/views/shared/topics/_search_form.html.haml @@ -1,6 +1,6 @@ = form_tag page_filter_path, method: :get, class: "topic-filter-form js-topic-filter-form", id: 'topic-filter-form' do |f| = search_field_tag :search, params[:search], - placeholder: s_('Filter by name'), + placeholder: _('Filter by name'), class: 'topic-filter-form-field form-control input-short', spellcheck: false, id: 'topic-filter-form-field', diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index ecb736dac4f..7eafd6ae092 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -1,13 +1,6 @@ = form_errors(hook) -- if Feature.enabled?(:webhook_form_mask_url) - .js-vue-webhook-form{ data: webhook_form_data(hook) } -- else - .form-group - = form.label :url, s_('Webhooks|URL'), class: 'label-bold' - = form.text_field :url, class: 'form-control gl-form-input', placeholder: 'http://example.com/trigger-ci.json' - %p.form-text.text-muted - = s_('Webhooks|URL must be percent-encoded if it contains one or more special characters.') +.js-vue-webhook-form{ data: webhook_form_data(hook) } .form-group = form.label :token, s_('Webhooks|Secret token'), class: 'label-bold' = form.password_field :token, value: hook.masked_token, autocomplete: 'new-password', class: 'form-control gl-form-input' @@ -19,66 +12,57 @@ = form.label :url, s_('Webhooks|Trigger'), class: 'label-bold' %ul.list-unstyled %li.gl-pb-5 - - if Feature.enabled?(:enhanced_webhook_support_regex) - - is_new_hook = hook.id.nil? - .js-vue-push-events{ data: { push_events: hook.push_events.to_s, strategy: hook.branch_filter_strategy, is_new_hook: is_new_hook.to_s, push_events_branch_filter: hook.push_events_branch_filter } } - - else - = form.gitlab_ui_checkbox_component :push_events, s_('Webhooks|Push events') - .gl-pl-6 - = form.text_field :push_events_branch_filter, class: 'form-control gl-form-input', - placeholder: 'Branch name or wildcard pattern to trigger on (leave blank for all)' - %p.form-text.text-muted.custom-control - = s_('Webhooks|Push to the repository.') + .js-vue-push-events{ data: { push_events: hook.push_events.to_s, strategy: hook.branch_filter_strategy, is_new_hook: hook.new_record?.to_s, push_events_branch_filter: hook.push_events_branch_filter } } %li.gl-pb-5 = form.gitlab_ui_checkbox_component :tag_push_events, - s_('Webhooks|Tag push events'), + integration_webhook_event_human_name(:tag_push_events), help_text: s_('Webhooks|A new tag is pushed to the repository.') %li.gl-pb-5 = form.gitlab_ui_checkbox_component :note_events, - s_('Webhooks|Comments'), + integration_webhook_event_human_name(:note_events), help_text: s_('Webhooks|A comment is added to an issue or merge request.') %li.gl-pb-5 = form.gitlab_ui_checkbox_component :confidential_note_events, - s_('Webhooks|Confidential comments'), + integration_webhook_event_human_name(:confidential_note_events), help_text: s_('Webhooks|A comment is added to a confidential issue.') %li.gl-pb-5 = form.gitlab_ui_checkbox_component :issues_events, - s_('Webhooks|Issues events'), + integration_webhook_event_human_name(:issues_events), help_text: s_('Webhooks|An issue is created, updated, closed, or reopened.') %li.gl-pb-5 = form.gitlab_ui_checkbox_component :confidential_issues_events, - s_('Webhooks|Confidential issues events'), + integration_webhook_event_human_name(:confidential_issues_events), help_text: s_('Webhooks|A confidential issue is created, updated, closed, or reopened.') - if @group = render_if_exists 'groups/hooks/member_events', form: form = render_if_exists 'groups/hooks/subgroup_events', form: form %li.gl-pb-5 = form.gitlab_ui_checkbox_component :merge_requests_events, - s_('Webhooks|Merge request events'), + integration_webhook_event_human_name(:merge_requests_events), help_text: s_('Webhooks|A merge request is created, updated, or merged.') %li.gl-pb-5 = form.gitlab_ui_checkbox_component :job_events, - s_('Webhooks|Job events'), + integration_webhook_event_human_name(:job_events), help_text: s_("Webhooks|A job's status changes.") %li.gl-pb-5 = form.gitlab_ui_checkbox_component :pipeline_events, - s_('Webhooks|Pipeline events'), + integration_webhook_event_human_name(:pipeline_events), help_text: s_("Webhooks|A pipeline's status changes.") %li.gl-pb-5 = form.gitlab_ui_checkbox_component :wiki_page_events, - s_('Webhooks|Wiki page events'), + integration_webhook_event_human_name(:wiki_page_events), help_text: s_('Webhooks|A wiki page is created or updated.') %li.gl-pb-5 = form.gitlab_ui_checkbox_component :deployment_events, - s_('Webhooks|Deployment events'), + integration_webhook_event_human_name(:deployment_events), help_text: s_('Webhooks|A deployment starts, finishes, fails, or is canceled.') %li.gl-pb-5 = form.gitlab_ui_checkbox_component :feature_flag_events, - s_('Webhooks|Feature flag events'), + integration_webhook_event_human_name(:feature_flag_events), help_text: s_('Webhooks|A feature flag is turned on or off.') %li.gl-pb-5 = form.gitlab_ui_checkbox_component :releases_events, - s_('Webhooks|Releases events'), + integration_webhook_event_human_name(:releases_events), help_text: s_('Webhooks|A release is created or updated.') .form-group = form.label :enable_ssl_verification, s_('Webhooks|SSL verification'), class: 'label-bold checkbox' diff --git a/app/views/shared/web_hooks/_hook.html.haml b/app/views/shared/web_hooks/_hook.html.haml index 529ef47a2cf..c19b518acd6 100644 --- a/app/views/shared/web_hooks/_hook.html.haml +++ b/app/views/shared/web_hooks/_hook.html.haml @@ -16,7 +16,7 @@ %div - hook.class.triggers.each_value do |trigger| - if hook.public_send(trigger) - = gl_badge_tag(trigger.to_s.titleize, size: :sm) + = gl_badge_tag(integration_webhook_event_human_name(trigger), size: :sm) = gl_badge_tag(sslBadgeText, size: :sm) .col-md-4.col-lg-5.text-right-md.gl-mt-2 diff --git a/app/views/shared/web_hooks/_test_button.html.haml b/app/views/shared/web_hooks/_test_button.html.haml index 3ffa45f01be..7a78a32fe87 100644 --- a/app/views/shared/web_hooks/_test_button.html.haml +++ b/app/views/shared/web_hooks/_test_button.html.haml @@ -2,12 +2,12 @@ - hook = local_assigns.fetch(:hook) - triggers = hook.class.triggers -.hook-test-button.dropdown.gl-new-dropdown.inline> +.hook-test-button.dropdown.gl-dropdown.inline> %button.btn.gl-button{ 'data-toggle' => 'dropdown', class: button_class } = _('Test') = sprite_icon('chevron-down') %ul.dropdown-menu.dropdown-menu-right{ role: 'menu' } - .gl-new-dropdown-inner + .gl-dropdown-inner - triggers.each_value do |event| - %li.gl-new-dropdown-item + %li.gl-dropdown-item = link_to_test_hook(hook, event) diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 7cef87ba19f..03ecf8cac22 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -25,7 +25,7 @@ - else = render Pajamas::ButtonComponent.new(href: new_abuse_report_path(user_id: @user.id, ref_url: request.referer), icon: 'error', - button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Report abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }}) + button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: _('Report abuse to administrator'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }}) - verified_gpg_keys = @user.gpg_keys.select(&:verified?) - if verified_gpg_keys.any? = render Pajamas::ButtonComponent.new(href: user_gpg_keys_path, diff --git a/app/views/web_ide/remote_ide/index.html.haml b/app/views/web_ide/remote_ide/index.html.haml new file mode 100644 index 00000000000..f007794d056 --- /dev/null +++ b/app/views/web_ide/remote_ide/index.html.haml @@ -0,0 +1,5 @@ +- data = local_assigns.fetch(:data) + +- page_title _('Web IDE') + += render partial: 'shared/ide_root', locals: { data: data, loading_text: _('Connecting to the remote environment...') } diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index b9168a65764..652a0021b0f 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -66,6 +66,24 @@ :weight: 3 :idempotent: false :tags: [] +- :name: batched_background_migrations:database_batched_background_migration_ci_execution + :worker_name: Database::BatchedBackgroundMigration::CiExecutionWorker + :feature_category: :database + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] +- :name: batched_background_migrations:database_batched_background_migration_main_execution + :worker_name: Database::BatchedBackgroundMigration::MainExecutionWorker + :feature_category: :database + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] - :name: chaos:chaos_cpu_spin :worker_name: Chaos::CpuSpinWorker :feature_category: :not_owned @@ -426,6 +444,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: cronjob:export_prune_project_export_jobs + :worker_name: Gitlab::Export::PruneProjectExportJobsWorker + :feature_category: :importers + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:gitlab_service_ping :worker_name: GitlabServicePingWorker :feature_category: :service_ping @@ -1038,6 +1065,33 @@ :weight: 1 :idempotent: false :tags: [] +- :name: github_gists_importer:github_gists_import_finish_import + :worker_name: Gitlab::GithubGistsImport::FinishImportWorker + :feature_category: :importers + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] +- :name: github_gists_importer:github_gists_import_import_gist + :worker_name: Gitlab::GithubGistsImport::ImportGistWorker + :feature_category: :importers + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] +- :name: github_gists_importer:github_gists_import_start_import + :worker_name: Gitlab::GithubGistsImport::StartImportWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] - :name: github_importer:github_import_attachments_import_issue :worker_name: Gitlab::GithubImport::Attachments::ImportIssueWorker :feature_category: :importers @@ -1380,6 +1434,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: jira_connect:jira_connect_send_uninstalled_hook + :worker_name: JiraConnect::SendUninstalledHookWorker + :feature_category: :integrations + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: jira_connect:jira_connect_sync_branch :worker_name: JiraConnect::SyncBranchWorker :feature_category: :integrations @@ -1560,15 +1623,6 @@ :weight: 1 :idempotent: false :tags: [] -- :name: object_storage:object_storage_background_move - :worker_name: ObjectStorage::BackgroundMoveWorker - :feature_category: :not_owned - :has_external_dependencies: false - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: false - :tags: [] - :name: object_storage:object_storage_migrate_uploads :worker_name: ObjectStorage::MigrateUploadsWorker :feature_category: :not_owned @@ -1623,6 +1677,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: package_repositories:packages_debian_process_package_file + :worker_name: Packages::Debian::ProcessPackageFileWorker + :feature_category: :package_registry + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: package_repositories:packages_go_sync_packages :worker_name: Packages::Go::SyncPackagesWorker :feature_category: :package_registry @@ -1810,7 +1873,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 4 - :idempotent: false + :idempotent: true :tags: [] - :name: pipeline_default:ci_create_cross_project_pipeline :worker_name: Ci::CreateCrossProjectPipelineWorker @@ -2730,15 +2793,6 @@ :weight: 1 :idempotent: true :tags: [] -- :name: merge_requests_delete_branch - :worker_name: MergeRequests::DeleteBranchWorker - :feature_category: :source_code_management - :has_external_dependencies: false - :urgency: :high - :resource_boundary: :unknown - :weight: 1 - :idempotent: true - :tags: [] - :name: merge_requests_delete_source_branch :worker_name: MergeRequests::DeleteSourceBranchWorker :feature_category: :source_code_management @@ -3009,6 +3063,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: projects_delete_branch + :worker_name: Projects::DeleteBranchWorker + :feature_category: :source_code_management + :has_external_dependencies: false + :urgency: :high + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: projects_git_garbage_collect :worker_name: Projects::GitGarbageCollectWorker :feature_category: :gitaly @@ -3018,6 +3081,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: projects_import_export_parallel_project_export + :worker_name: Projects::ImportExport::ParallelProjectExportWorker + :feature_category: :importers + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :memory + :weight: 1 + :idempotent: true + :tags: [] - :name: projects_import_export_relation_export :worker_name: Projects::ImportExport::RelationExportWorker :feature_category: :importers @@ -3281,7 +3353,7 @@ :tags: [] - :name: update_highest_role :worker_name: UpdateHighestRoleWorker - :feature_category: :subscription_usage_reports + :feature_category: :subscription_cost_management :has_external_dependencies: false :urgency: :high :resource_boundary: :unknown diff --git a/app/workers/bulk_imports/entity_worker.rb b/app/workers/bulk_imports/entity_worker.rb index d23d57c33ab..fb99d63d06e 100644 --- a/app/workers/bulk_imports/entity_worker.rb +++ b/app/workers/bulk_imports/entity_worker.rb @@ -74,6 +74,8 @@ module BulkImports source_version: source_version, importer: 'gitlab_migration' ) + + entity.fail_op! end private diff --git a/app/workers/bulk_imports/export_request_worker.rb b/app/workers/bulk_imports/export_request_worker.rb index 1a5f6250429..530419dac26 100644 --- a/app/workers/bulk_imports/export_request_worker.rb +++ b/app/workers/bulk_imports/export_request_worker.rb @@ -4,11 +4,15 @@ module BulkImports class ExportRequestWorker include ApplicationWorker - data_consistency :always - idempotent! - worker_has_external_dependencies! + data_consistency :always feature_category :importers + sidekiq_options dead: false, retry: 5 + worker_has_external_dependencies! + + sidekiq_retries_exhausted do |msg, exception| + new.perform_failure(exception, msg['args'].first) + end def perform(entity_id) entity = BulkImports::Entity.find(entity_id) @@ -18,26 +22,12 @@ module BulkImports request_export(entity) BulkImports::EntityWorker.perform_async(entity_id) - rescue BulkImports::NetworkError => e - if e.retriable?(entity) - retry_request(e, entity) - else - log_exception(e, - { - bulk_import_entity_id: entity.id, - bulk_import_id: entity.bulk_import_id, - bulk_import_entity_type: entity.source_type, - source_full_path: entity.source_full_path, - message: "Request to export #{entity.source_type} failed", - source_version: entity.bulk_import.source_version_info.to_s, - importer: 'gitlab_migration' - } - ) - - BulkImports::Failure.create(failure_attributes(e, entity)) - - entity.fail_op! - end + end + + def perform_failure(exception, entity_id) + entity = BulkImports::Entity.find(entity_id) + + log_and_fail(exception, entity) end private @@ -104,30 +94,32 @@ module BulkImports end end - def retry_request(exception, entity) + def logger + @logger ||= Gitlab::Import::Logger.build + end + + def log_exception(exception, payload) + Gitlab::ExceptionLogFormatter.format!(exception, payload) + + logger.error(structured_payload(payload)) + end + + def log_and_fail(exception, entity) log_exception(exception, { - message: 'Retrying export request', bulk_import_entity_id: entity.id, bulk_import_id: entity.bulk_import_id, bulk_import_entity_type: entity.source_type, source_full_path: entity.source_full_path, + message: "Request to export #{entity.source_type} failed", source_version: entity.bulk_import.source_version_info.to_s, importer: 'gitlab_migration' } ) - self.class.perform_in(2.seconds, entity.id) - end - - def logger - @logger ||= Gitlab::Import::Logger.build - end - - def log_exception(exception, payload) - Gitlab::ExceptionLogFormatter.format!(exception, payload) + BulkImports::Failure.create(failure_attributes(exception, entity)) - logger.error(structured_payload(payload)) + entity.fail_op! end end end diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb index 5716f6e3f31..62e85d38e61 100644 --- a/app/workers/bulk_imports/pipeline_worker.rb +++ b/app/workers/bulk_imports/pipeline_worker.rb @@ -3,6 +3,7 @@ module BulkImports class PipelineWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + include ExclusiveLeaseGuard FILE_EXTRACTION_PIPELINE_PERFORM_DELAY = 10.seconds @@ -10,44 +11,24 @@ module BulkImports feature_category :importers sidekiq_options retry: false, dead: false worker_has_external_dependencies! + deduplicate :until_executing def perform(pipeline_tracker_id, stage, entity_id) - @pipeline_tracker = ::BulkImports::Tracker - .with_status(:enqueued) - .find_by_id(pipeline_tracker_id) - - if pipeline_tracker.present? - @entity = @pipeline_tracker.entity - - logger.info( - structured_payload( - bulk_import_entity_id: entity.id, - bulk_import_id: entity.bulk_import_id, - bulk_import_entity_type: entity.source_type, - source_full_path: entity.source_full_path, - pipeline_name: pipeline_tracker.pipeline_name, - message: 'Pipeline starting', - source_version: source_version, - importer: 'gitlab_migration' - ) - ) - - run - else - @entity = ::BulkImports::Entity.find(entity_id) - - logger.error( - structured_payload( - bulk_import_entity_id: entity_id, - bulk_import_id: entity.bulk_import_id, - bulk_import_entity_type: entity.source_type, - source_full_path: entity.source_full_path, - pipeline_tracker_id: pipeline_tracker_id, - message: 'Unstarted pipeline not found', - source_version: source_version, - importer: 'gitlab_migration' - ) - ) + @entity = ::BulkImports::Entity.find(entity_id) + @pipeline_tracker = ::BulkImports::Tracker.find(pipeline_tracker_id) + + try_obtain_lease do + if pipeline_tracker.enqueued? + logger.info(log_attributes(message: 'Pipeline starting')) + + run + else + message = "Pipeline in #{pipeline_tracker.human_status_name} state instead of expected enqueued state" + + logger.error(log_attributes(message: message)) + + fail_tracker(StandardError.new(message)) unless pipeline_tracker.finished? || pipeline_tracker.skipped? + end end ensure @@ -63,6 +44,7 @@ module BulkImports raise(Pipeline::ExpiredError, 'Pipeline timeout') if job_timeout? raise(Pipeline::FailedError, "Export from source instance failed: #{export_status.error}") if export_failed? + raise(Pipeline::ExpiredError, 'Empty export status on source instance') if empty_export_timeout? return re_enqueue if export_empty? || export_started? @@ -82,29 +64,9 @@ module BulkImports def fail_tracker(exception) pipeline_tracker.update!(status_event: 'fail_op', jid: jid) - log_exception(exception, - { - bulk_import_entity_id: entity.id, - bulk_import_id: entity.bulk_import_id, - bulk_import_entity_type: entity.source_type, - source_full_path: entity.source_full_path, - pipeline_name: pipeline_tracker.pipeline_name, - message: 'Pipeline failed', - source_version: source_version, - importer: 'gitlab_migration' - } - ) + log_exception(exception, log_attributes(message: 'Pipeline failed')) - Gitlab::ErrorTracking.track_exception( - exception, - bulk_import_entity_id: entity.id, - bulk_import_id: entity.bulk_import_id, - bulk_import_entity_type: entity.source_type, - source_full_path: entity.source_full_path, - pipeline_name: pipeline_tracker.pipeline_name, - source_version: source_version, - importer: 'gitlab_migration' - ) + Gitlab::ErrorTracking.track_exception(exception, log_attributes) BulkImports::Failure.create( bulk_import_entity_id: entity.id, @@ -144,7 +106,11 @@ module BulkImports def job_timeout? return false unless file_extraction_pipeline? - (Time.zone.now - entity.created_at) > Pipeline::NDJSON_EXPORT_TIMEOUT + time_since_entity_created > Pipeline::NDJSON_EXPORT_TIMEOUT + end + + def empty_export_timeout? + export_empty? && time_since_entity_created > Pipeline::EMPTY_EXPORT_STATUS_TIMEOUT end def export_failed? @@ -166,18 +132,7 @@ module BulkImports end def retry_tracker(exception) - log_exception(exception, - { - bulk_import_entity_id: entity.id, - bulk_import_id: entity.bulk_import_id, - bulk_import_entity_type: entity.source_type, - source_full_path: entity.source_full_path, - pipeline_name: pipeline_tracker.pipeline_name, - message: "Retrying pipeline", - source_version: source_version, - importer: 'gitlab_migration' - } - ) + log_exception(exception, log_attributes(message: "Retrying pipeline")) pipeline_tracker.update!(status_event: 'retry', jid: jid) @@ -185,25 +140,43 @@ module BulkImports end def skip_tracker - logger.info( - structured_payload( + logger.info(log_attributes(message: 'Skipping pipeline due to failed entity')) + + pipeline_tracker.update!(status_event: 'skip', jid: jid) + end + + def log_attributes(extra = {}) + structured_payload( + { bulk_import_entity_id: entity.id, bulk_import_id: entity.bulk_import_id, bulk_import_entity_type: entity.source_type, source_full_path: entity.source_full_path, + pipeline_tracker_id: pipeline_tracker.id, pipeline_name: pipeline_tracker.pipeline_name, - message: 'Skipping pipeline due to failed entity', + pipeline_tracker_state: pipeline_tracker.human_status_name, source_version: source_version, importer: 'gitlab_migration' - ) + }.merge(extra) ) - - pipeline_tracker.update!(status_event: 'skip', jid: jid) end def log_exception(exception, payload) Gitlab::ExceptionLogFormatter.format!(exception, payload) + logger.error(structured_payload(payload)) end + + def time_since_entity_created + Time.zone.now - entity.created_at + end + + def lease_timeout + 30 + end + + def lease_key + "gitlab:bulk_imports:pipeline_worker:#{pipeline_tracker.id}" + end end end diff --git a/app/workers/ci/create_downstream_pipeline_worker.rb b/app/workers/ci/create_downstream_pipeline_worker.rb index 747cb088272..9f5ff45b8a6 100644 --- a/app/workers/ci/create_downstream_pipeline_worker.rb +++ b/app/workers/ci/create_downstream_pipeline_worker.rb @@ -11,9 +11,15 @@ module Ci def perform(bridge_id) ::Ci::Bridge.find_by_id(bridge_id).try do |bridge| - ::Ci::CreateDownstreamPipelineService + result = ::Ci::CreateDownstreamPipelineService .new(bridge.project, bridge.user) .execute(bridge) + + if result.success? + log_extra_metadata_on_done(:new_pipeline_id, result.payload.id) + else + log_extra_metadata_on_done(:create_error_message, result.message) + end end end end diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb index 9793278ac0c..c5c7da23892 100644 --- a/app/workers/concerns/gitlab/github_import/object_importer.rb +++ b/app/workers/concerns/gitlab/github_import/object_importer.rb @@ -47,6 +47,9 @@ module Gitlab # Representation is created but the developer forgot to add a # `:github_identifiers` field. track_and_raise_exception(project, e, fail_import: true) + rescue ActiveRecord::RecordInvalid => e + # We do not raise exception to prevent job retry + track_exception(project, e) rescue StandardError => e track_and_raise_exception(project, e) end @@ -86,13 +89,17 @@ module Gitlab ) end - def track_and_raise_exception(project, exception, fail_import: false) + def track_exception(project, exception, fail_import: false) Gitlab::Import::ImportFailureService.track( project_id: project.id, error_source: importer_class.name, exception: exception, fail_import: fail_import ) + end + + def track_and_raise_exception(project, exception, fail_import: false) + track_exception(project, exception, fail_import: fail_import) raise(exception) end diff --git a/app/workers/concerns/waitable_worker.rb b/app/workers/concerns/waitable_worker.rb index 9300c2a5790..f23e3fb20c2 100644 --- a/app/workers/concerns/waitable_worker.rb +++ b/app/workers/concerns/waitable_worker.rb @@ -6,33 +6,8 @@ module WaitableWorker class_methods do # Schedules multiple jobs and waits for them to be completed. def bulk_perform_and_wait(args_list) - # Short-circuit: it's more efficient to do small numbers of jobs inline - if args_list.size == 1 && !always_async_project_authorizations_refresh? - return bulk_perform_inline(args_list) - end - bulk_perform_async(args_list) end - - # Performs multiple jobs directly. Failed jobs will be put into sidekiq so - # they can benefit from retries - def bulk_perform_inline(args_list) - failed = [] - - args_list.each do |args| - worker = new - Gitlab::AppJsonLogger.info(worker.structured_payload(message: 'running inline')) - worker.perform(*args) - rescue StandardError - failed << args - end - - bulk_perform_async(failed) if failed.present? - end - - def always_async_project_authorizations_refresh? - Feature.enabled?(:always_async_project_authorizations_refresh) - end end def perform(*args) diff --git a/app/workers/container_registry/cleanup_worker.rb b/app/workers/container_registry/cleanup_worker.rb index 8350ae3431b..a838b97b35d 100644 --- a/app/workers/container_registry/cleanup_worker.rb +++ b/app/workers/container_registry/cleanup_worker.rb @@ -15,8 +15,6 @@ module ContainerRegistry BATCH_SIZE = 200 def perform - return unless Feature.enabled?(:container_registry_delete_repository_with_cron_worker) - log_counts reset_stale_deletes diff --git a/app/workers/container_registry/delete_container_repository_worker.rb b/app/workers/container_registry/delete_container_repository_worker.rb index 1f94b1b9e71..d6ecd836ed2 100644 --- a/app/workers/container_registry/delete_container_repository_worker.rb +++ b/app/workers/container_registry/delete_container_repository_worker.rb @@ -17,6 +17,7 @@ module ContainerRegistry MAX_CAPACITY = 2 CLEANUP_TAGS_SERVICE_PARAMS = { 'name_regex_delete' => '.*', + 'keep_latest' => false, 'container_expiration_policy' => true # to avoid permissions checks }.freeze diff --git a/app/workers/container_registry/migration/enqueuer_worker.rb b/app/workers/container_registry/migration/enqueuer_worker.rb index 1dd29eff86e..a4f5ac8eb7e 100644 --- a/app/workers/container_registry/migration/enqueuer_worker.rb +++ b/app/workers/container_registry/migration/enqueuer_worker.rb @@ -130,13 +130,13 @@ module ContainerRegistry # this issue. # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87733 and # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/90735 for details. - ContainerRepository.ready_for_import.limit(25)[0] # rubocop:disable CodeReuse/ActiveRecord + ContainerRepository.ready_for_import.ordered.limit(25)[0] # rubocop:disable CodeReuse/ActiveRecord end end def next_aborted_repository strong_memoize(:next_aborted_repository) do - ContainerRepository.with_migration_state('import_aborted').limit(25)[0] # rubocop:disable CodeReuse/ActiveRecord + ContainerRepository.with_migration_state('import_aborted').ordered.limit(25)[0] # rubocop:disable CodeReuse/ActiveRecord end end diff --git a/app/workers/database/batched_background_migration/ci_database_worker.rb b/app/workers/database/batched_background_migration/ci_database_worker.rb index b04db87631a..58b0f5496f4 100644 --- a/app/workers/database/batched_background_migration/ci_database_worker.rb +++ b/app/workers/database/batched_background_migration/ci_database_worker.rb @@ -7,6 +7,10 @@ module Database def self.tracking_database @tracking_database ||= Gitlab::Database::CI_DATABASE_NAME.to_sym end + + def execution_worker_class + @execution_worker_class ||= Database::BatchedBackgroundMigration::CiExecutionWorker + end end end end diff --git a/app/workers/database/batched_background_migration/ci_execution_worker.rb b/app/workers/database/batched_background_migration/ci_execution_worker.rb new file mode 100644 index 00000000000..89c70e29dda --- /dev/null +++ b/app/workers/database/batched_background_migration/ci_execution_worker.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Database + module BatchedBackgroundMigration + class CiExecutionWorker # rubocop:disable Scalability/IdempotentWorker + include ExecutionWorker + end + end +end diff --git a/app/workers/database/batched_background_migration/execution_worker.rb b/app/workers/database/batched_background_migration/execution_worker.rb index 098153c742f..b59e4bd1f86 100644 --- a/app/workers/database/batched_background_migration/execution_worker.rb +++ b/app/workers/database/batched_background_migration/execution_worker.rb @@ -2,14 +2,47 @@ module Database module BatchedBackgroundMigration - class ExecutionWorker # rubocop:disable Scalability/IdempotentWorker + module ExecutionWorker + extend ActiveSupport::Concern include ExclusiveLeaseGuard include Gitlab::Utils::StrongMemoize + include ApplicationWorker + include LimitedCapacity::Worker INTERVAL_VARIANCE = 5.seconds.freeze LEASE_TIMEOUT_MULTIPLIER = 3 + MAX_RUNNING_MIGRATIONS = 2 - def perform(database_name, migration_id) + included do + data_consistency :always + feature_category :database + queue_namespace :batched_background_migrations + end + + class_methods do + def max_running_jobs + MAX_RUNNING_MIGRATIONS + end + + # We have to overirde this one, as we want + # arguments passed as is, and not duplicated + def perform_with_capacity(args) + worker = new + worker.remove_failed_jobs + + bulk_perform_async(args) # rubocop:disable Scalability/BulkPerformWithContext + end + end + + def remaining_work_count(*args) + 0 # the cron worker is the only source of new jobs + end + + def max_running_jobs + self.class.max_running_jobs + end + + def perform_work(database_name, migration_id) self.database_name = database_name return unless enabled? diff --git a/app/workers/database/batched_background_migration/main_execution_worker.rb b/app/workers/database/batched_background_migration/main_execution_worker.rb new file mode 100644 index 00000000000..661496a86a9 --- /dev/null +++ b/app/workers/database/batched_background_migration/main_execution_worker.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Database + module BatchedBackgroundMigration + class MainExecutionWorker # rubocop:disable Scalability/IdempotentWorker + include ExecutionWorker + end + end +end diff --git a/app/workers/database/batched_background_migration/single_database_worker.rb b/app/workers/database/batched_background_migration/single_database_worker.rb index 0c7c51d5c0a..e772216e557 100644 --- a/app/workers/database/batched_background_migration/single_database_worker.rb +++ b/app/workers/database/batched_background_migration/single_database_worker.rb @@ -39,7 +39,7 @@ module Database unless base_model Sidekiq.logger.info( class: self.class.name, - database: self.class.tracking_database, + database: tracking_database, message: 'skipping migration execution for unconfigured database') return @@ -48,34 +48,61 @@ module Database if shares_db_config? Sidekiq.logger.info( class: self.class.name, - database: self.class.tracking_database, + database: tracking_database, message: 'skipping migration execution for database that shares database configuration with another database') return end Gitlab::Database::SharedModel.using_connection(base_model.connection) do - break unless self.class.enabled? && active_migration + break unless self.class.enabled? - with_exclusive_lease(active_migration.interval) do - run_active_migration + if parallel_execution_enabled? + migrations = Gitlab::Database::BackgroundMigration::BatchedMigration + .active_migrations_distinct_on_table(connection: base_model.connection, limit: max_running_migrations).to_a + + queue_migrations_for_execution(migrations) if migrations.any? + else + break unless active_migration + + with_exclusive_lease(active_migration.interval) do + run_active_migration + end end end end private + def parallel_execution_enabled? + Feature.enabled?(:batched_migrations_parallel_execution) + end + + def max_running_migrations + execution_worker_class.max_running_jobs + end + def active_migration @active_migration ||= Gitlab::Database::BackgroundMigration::BatchedMigration.active_migration(connection: base_model.connection) end def run_active_migration - Database::BatchedBackgroundMigration::ExecutionWorker.new.perform(self.class.tracking_database, active_migration.id) + execution_worker_class.new.perform_work(tracking_database, active_migration.id) + end + + def tracking_database + self.class.tracking_database + end + + def queue_migrations_for_execution(migrations) + jobs_arguments = migrations.map { |migration| [tracking_database.to_s, migration.id] } + + execution_worker_class.perform_with_capacity(jobs_arguments) end def base_model strong_memoize(:base_model) do - Gitlab::Database.database_base_models[self.class.tracking_database] + Gitlab::Database.database_base_models[tracking_database] end end diff --git a/app/workers/database/batched_background_migration_worker.rb b/app/workers/database/batched_background_migration_worker.rb index 29804be832d..1450613dd89 100644 --- a/app/workers/database/batched_background_migration_worker.rb +++ b/app/workers/database/batched_background_migration_worker.rb @@ -7,5 +7,9 @@ module Database def self.tracking_database @tracking_database ||= Gitlab::Database::MAIN_DATABASE_NAME.to_sym end + + def execution_worker_class + @execution_worker_class ||= Database::BatchedBackgroundMigration::MainExecutionWorker + end end end diff --git a/app/workers/delete_container_repository_worker.rb b/app/workers/delete_container_repository_worker.rb index 73e6843fdd0..d0552dce9fc 100644 --- a/app/workers/delete_container_repository_worker.rb +++ b/app/workers/delete_container_repository_worker.rb @@ -11,64 +11,5 @@ class DeleteContainerRepositoryWorker # rubocop:disable Scalability/IdempotentWo queue_namespace :container_repository feature_category :container_registry - LEASE_TIMEOUT = 1.hour.freeze - FIXED_DELAY = 10.seconds.freeze - - attr_reader :container_repository - - def perform(current_user_id, container_repository_id) - current_user = User.find_by_id(current_user_id) - @container_repository = ContainerRepository.find_by_id(container_repository_id) - project = container_repository&.project - - return unless current_user && container_repository && project - - if migration.delete_container_repository_worker_support? && migrating? - delay = migration_duration - - self.class.perform_in(delay.from_now) - - log_extra_metadata_on_done(:delete_postponed, delay) - - return - end - - # If a user accidentally attempts to delete the same container registry in quick succession, - # this can lead to orphaned tags. - try_obtain_lease do - Projects::ContainerRepository::DestroyService.new(project, current_user).execute(container_repository) - end - end - - private - - def migrating? - !(container_repository.default? || - container_repository.import_done? || - container_repository.import_skipped?) - end - - def migration_duration - duration = migration.import_timeout.seconds + FIXED_DELAY - - if container_repository.pre_importing? - duration += migration.dynamic_pre_import_timeout_for(container_repository) - end - - duration - end - - def migration - ContainerRegistry::Migration - end - - # For ExclusiveLeaseGuard concern - def lease_key - @lease_key ||= "container_repository:delete:#{container_repository.id}" - end - - # For ExclusiveLeaseGuard concern - def lease_timeout - LEASE_TIMEOUT - end + def perform(current_user_id, container_repository_id); end end diff --git a/app/workers/flush_counter_increments_worker.rb b/app/workers/flush_counter_increments_worker.rb index 8c7e17587d0..e92e1a9b7b5 100644 --- a/app/workers/flush_counter_increments_worker.rb +++ b/app/workers/flush_counter_increments_worker.rb @@ -29,6 +29,6 @@ class FlushCounterIncrementsWorker model = model_class.find_by_id(model_id) return unless model - model.flush_increments_to_database!(attribute) + Gitlab::Counters::BufferedCounter.new(model, attribute).commit_increment! end end diff --git a/app/workers/gitlab/export/prune_project_export_jobs_worker.rb b/app/workers/gitlab/export/prune_project_export_jobs_worker.rb new file mode 100644 index 00000000000..9a3c0c80f85 --- /dev/null +++ b/app/workers/gitlab/export/prune_project_export_jobs_worker.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Gitlab + module Export + class PruneProjectExportJobsWorker + include ApplicationWorker + + # rubocop:disable Scalability/CronWorkerContext + # This worker updates several import states inline and does not schedule + # other jobs. So no context needed + include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext + + feature_category :importers + data_consistency :always + idempotent! + + def perform + ProjectExportJob.prune_expired_jobs + end + end + end +end diff --git a/app/workers/gitlab/github_gists_import/finish_import_worker.rb b/app/workers/gitlab/github_gists_import/finish_import_worker.rb new file mode 100644 index 00000000000..1989b6314ea --- /dev/null +++ b/app/workers/gitlab/github_gists_import/finish_import_worker.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Gitlab + module GithubGistsImport + class FinishImportWorker + include ApplicationWorker + + data_consistency :always + queue_namespace :github_gists_importer + feature_category :importers + idempotent! + + sidekiq_options dead: false, retry: 5 + + sidekiq_retries_exhausted do |msg, _| + Gitlab::GithubGistsImport::Status.new(msg['args'][0]).fail! + end + + INTERVAL = 30.seconds.to_i + BLOCKING_WAIT_TIME = 5 + + def perform(user_id, waiter_key, remaining) + waiter = wait_for_jobs(waiter_key, remaining) + + if waiter.nil? + Gitlab::GithubGistsImport::Status.new(user_id).finish! + + Gitlab::GithubImport::Logger.info(user_id: user_id, message: 'GitHub Gists import finished') + else + self.class.perform_in(INTERVAL, user_id, waiter.key, waiter.jobs_remaining) + end + end + + private + + def wait_for_jobs(key, remaining) + waiter = JobWaiter.new(remaining, key) + waiter.wait(BLOCKING_WAIT_TIME) + + return if waiter.jobs_remaining == 0 + + waiter + end + end + end +end diff --git a/app/workers/gitlab/github_gists_import/import_gist_worker.rb b/app/workers/gitlab/github_gists_import/import_gist_worker.rb new file mode 100644 index 00000000000..7e2b3709597 --- /dev/null +++ b/app/workers/gitlab/github_gists_import/import_gist_worker.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Gitlab + module GithubGistsImport + class ImportGistWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + include Gitlab::NotifyUponDeath + + data_consistency :always + queue_namespace :github_gists_importer + feature_category :importers + + sidekiq_options dead: false, retry: 5 + + def perform(user_id, gist_hash, notify_key) + gist = ::Gitlab::GithubGistsImport::Representation::Gist.from_json_hash(gist_hash) + + with_logging(user_id, gist.github_identifiers) do + result = importer_class.new(gist, user_id).execute + error(user_id, result.errors, gist.github_identifiers) unless result.success? + + JobWaiter.notify(notify_key, jid) + end + rescue StandardError => e + log_and_track_error(user_id, e, gist.github_identifiers) + + raise + end + + private + + def importer_class + ::Gitlab::GithubGistsImport::Importer::GistImporter + end + + def with_logging(user_id, gist_id) + info(user_id, 'start importer', gist_id) + + yield + + info(user_id, 'importer finished', gist_id) + end + + def log_and_track_error(user_id, exception, gist_id) + error(user_id, exception.message, gist_id) + + Gitlab::ErrorTracking.track_exception(exception, + import_type: :github_gists, + user_id: user_id + ) + end + + def error(user_id, error_message, gist_id) + attributes = { + user_id: user_id, + github_identifiers: gist_id, + message: 'importer failed', + 'error.message': error_message + } + + Gitlab::GithubImport::Logger.error(structured_payload(attributes)) + end + + def info(user_id, message, gist_id) + attributes = { + user_id: user_id, + message: message, + github_identifiers: gist_id + } + + Gitlab::GithubImport::Logger.info(structured_payload(attributes)) + end + end + end +end diff --git a/app/workers/gitlab/github_gists_import/start_import_worker.rb b/app/workers/gitlab/github_gists_import/start_import_worker.rb new file mode 100644 index 00000000000..33c91611719 --- /dev/null +++ b/app/workers/gitlab/github_gists_import/start_import_worker.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Gitlab + module GithubGistsImport + class StartImportWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + data_consistency :always + queue_namespace :github_gists_importer + feature_category :importers + + sidekiq_options dead: false, retry: 5 + + worker_has_external_dependencies! + + sidekiq_retries_exhausted do |msg, _| + Gitlab::GithubGistsImport::Status.new(msg['args'][0]).fail! + + user = User.find(msg['args'][0]) + Gitlab::GithubImport::PageCounter.new(user, :gists, 'github-gists-importer').expire! + end + + def perform(user_id, encrypted_token) + logger.info(structured_payload(user_id: user_id, message: 'starting importer')) + + user = User.find(user_id) + decrypted_token = Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token) + result = Gitlab::GithubGistsImport::Importer::GistsImporter.new(user, decrypted_token).execute + + if result.success? + schedule_finish_worker(user_id, result.waiter) + elsif result.next_attempt_in + schedule_next_attempt(result.next_attempt_in, user_id, encrypted_token) + else + log_error_and_raise!(user_id, result.error) + end + end + + private + + def schedule_finish_worker(user_id, waiter) + logger.info(structured_payload(user_id: user_id, message: 'importer finished')) + + Gitlab::GithubGistsImport::FinishImportWorker.perform_async(user_id, waiter.key, waiter.jobs_remaining) + end + + def schedule_next_attempt(next_attempt_in, user_id, encrypted_token) + logger.info(structured_payload(user_id: user_id, message: 'rate limit reached')) + + self.class.perform_in(next_attempt_in, user_id, encrypted_token) + end + + def log_error_and_raise!(user_id, error) + logger.error(structured_payload(user_id: user_id, message: 'import failed', 'error.message': error.message)) + + raise(error) + end + + def logger + Gitlab::GithubImport::Logger + end + end + end +end diff --git a/app/workers/gitlab_shell_worker.rb b/app/workers/gitlab_shell_worker.rb index 2f396dcdb86..b3c0fa79658 100644 --- a/app/workers/gitlab_shell_worker.rb +++ b/app/workers/gitlab_shell_worker.rb @@ -14,7 +14,7 @@ class GitlabShellWorker # rubocop:disable Scalability/IdempotentWorker loggable_arguments 0 def perform(action, *arg) - if ::Feature.enabled?(:verify_gitlab_shell_worker_method_names) && Gitlab::Shell::PERMITTED_ACTIONS.exclude?(action) + if Gitlab::Shell::PERMITTED_ACTIONS.exclude?(action) raise(ArgumentError, "#{action} not allowed for #{self.class.name}") end diff --git a/app/workers/issuable_export_csv_worker.rb b/app/workers/issuable_export_csv_worker.rb index ffa0ed68fc7..1c5fab8c4c0 100644 --- a/app/workers/issuable_export_csv_worker.rb +++ b/app/workers/issuable_export_csv_worker.rb @@ -24,7 +24,7 @@ class IssuableExportCsvWorker # rubocop:disable Scalability/IdempotentWorker def export_service(type, user, project, params) issuable_classes = issuable_classes_for(type.to_sym) - issuables = issuable_classes[:finder].new(user, parse_params(params, project.id)).execute + issuables = issuable_classes[:finder].new(user, parse_params(params, project.id, type)).execute issuable_classes[:service].new(issuables, project) end @@ -39,7 +39,7 @@ class IssuableExportCsvWorker # rubocop:disable Scalability/IdempotentWorker end end - def parse_params(params, project_id) + def parse_params(params, project_id, _type) params .with_indifferent_access .except(:sort) diff --git a/app/workers/jira_connect/send_uninstalled_hook_worker.rb b/app/workers/jira_connect/send_uninstalled_hook_worker.rb new file mode 100644 index 00000000000..530ef4a8b8a --- /dev/null +++ b/app/workers/jira_connect/send_uninstalled_hook_worker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module JiraConnect + class SendUninstalledHookWorker + include ApplicationWorker + + data_consistency :delayed + queue_namespace :jira_connect + feature_category :integrations + urgency :low + + idempotent! + + worker_has_external_dependencies! + + def perform(installation_id, instance_url) + installation = JiraConnectInstallation.find_by_id(installation_id) + + JiraConnectInstallations::ProxyLifecycleEventService.execute(installation, :uninstalled, instance_url) + end + end +end diff --git a/app/workers/mail_scheduler/notification_service_worker.rb b/app/workers/mail_scheduler/notification_service_worker.rb index 12e8de4491e..8206a17021b 100644 --- a/app/workers/mail_scheduler/notification_service_worker.rb +++ b/app/workers/mail_scheduler/notification_service_worker.rb @@ -18,9 +18,7 @@ module MailScheduler def perform(meth, *args) check_arguments!(args) - if ::Feature.enabled?(:verify_mail_scheduler_notification_service_worker_method_names) && - NotificationService.permitted_actions.exclude?(meth.to_sym) - + if NotificationService.permitted_actions.exclude?(meth.to_sym) raise(ArgumentError, "#{meth} not allowed for #{self.class.name}") end diff --git a/app/workers/merge_requests/delete_branch_worker.rb b/app/workers/merge_requests/delete_branch_worker.rb deleted file mode 100644 index 6816f9a4b77..00000000000 --- a/app/workers/merge_requests/delete_branch_worker.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module MergeRequests - class DeleteBranchWorker - include ApplicationWorker - - data_consistency :always - - feature_category :source_code_management - urgency :high - idempotent! - - def perform(merge_request_id, user_id, branch_name, retarget_branch) - merge_request = MergeRequest.find_by_id(merge_request_id) - user = User.find_by_id(user_id) - - return unless merge_request.present? && user.present? - - ::Branches::DeleteService.new(merge_request.source_project, user).execute(branch_name) - - return unless retarget_branch - - ::MergeRequests::RetargetChainService.new(project: merge_request.source_project, current_user: user) - .execute(merge_request) - end - end -end diff --git a/app/workers/merge_requests/delete_source_branch_worker.rb b/app/workers/merge_requests/delete_source_branch_worker.rb index 96dde413d5b..da1eca067a9 100644 --- a/app/workers/merge_requests/delete_source_branch_worker.rb +++ b/app/workers/merge_requests/delete_source_branch_worker.rb @@ -19,13 +19,14 @@ class MergeRequests::DeleteSourceBranchWorker return if merge_request.source_branch_sha != source_branch_sha if Feature.enabled?(:add_delete_branch_worker, merge_request.source_project) - ::MergeRequests::DeleteBranchWorker.perform_async(merge_request_id, user_id, merge_request.source_branch, true) + ::Projects::DeleteBranchWorker.new.perform(merge_request.source_project.id, user_id, + merge_request.source_branch) else ::Branches::DeleteService.new(merge_request.source_project, user).execute(merge_request.source_branch) - - ::MergeRequests::RetargetChainService.new(project: merge_request.source_project, current_user: user) - .execute(merge_request) end + + ::MergeRequests::RetargetChainService.new(project: merge_request.source_project, current_user: user) + .execute(merge_request) rescue ActiveRecord::RecordNotFound end end diff --git a/app/workers/namespaces/root_statistics_worker.rb b/app/workers/namespaces/root_statistics_worker.rb index e3aa8a1f779..02b3468c052 100644 --- a/app/workers/namespaces/root_statistics_worker.rb +++ b/app/workers/namespaces/root_statistics_worker.rb @@ -4,7 +4,7 @@ module Namespaces class RootStatisticsWorker include ApplicationWorker - data_consistency :sticky, feature_flag: :root_statistics_worker_read_replica + data_consistency :sticky sidekiq_options retry: 3 diff --git a/app/workers/object_storage/background_move_worker.rb b/app/workers/object_storage/background_move_worker.rb deleted file mode 100644 index bb51f0d7e1f..00000000000 --- a/app/workers/object_storage/background_move_worker.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -module ObjectStorage - class BackgroundMoveWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker - - data_consistency :always - include ObjectStorageQueue - - sidekiq_options retry: 5 - feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned - loggable_arguments 0, 1, 2, 3 - - def perform(uploader_class_name, subject_class_name, file_field, subject_id) - uploader_class = uploader_class_name.constantize - subject_class = subject_class_name.constantize - - return unless uploader_class < ObjectStorage::Concern - return unless uploader_class.object_store_enabled? - return unless uploader_class.background_upload_enabled? - - subject = subject_class.find(subject_id) - uploader = build_uploader(subject, file_field&.to_sym) - uploader.migrate!(ObjectStorage::Store::REMOTE) - end - - def build_uploader(subject, mount_point) - case subject - when Upload then subject.retrieve_uploader(mount_point) - else - subject.send(mount_point) # rubocop:disable GitlabSecurity/PublicSend - end - end - end -end diff --git a/app/workers/packages/debian/process_package_file_worker.rb b/app/workers/packages/debian/process_package_file_worker.rb new file mode 100644 index 00000000000..587c0b78c9c --- /dev/null +++ b/app/workers/packages/debian/process_package_file_worker.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Packages + module Debian + class ProcessPackageFileWorker + include ApplicationWorker + include ::Packages::FIPS + include Gitlab::Utils::StrongMemoize + + data_consistency :always + + deduplicate :until_executed + idempotent! + + queue_namespace :package_repositories + feature_category :package_registry + + def perform(package_file_id, user_id, distribution_name, component_name) + raise DisabledError, 'Debian registry is not FIPS compliant' if Gitlab::FIPS.enabled? + + @package_file_id = package_file_id + @user_id = user_id + @distribution_name = distribution_name + @component_name = component_name + + return unless package_file && user && distribution_name && component_name + # return if file has already been processed + return unless package_file.debian_file_metadatum&.unknown? + + ::Packages::Debian::ProcessPackageFileService.new(package_file, user, distribution_name, component_name).execute + rescue StandardError => e + raise if e.instance_of?(DisabledError) + + Gitlab::ErrorTracking.log_exception(e, package_file_id: @package_file_id, user_id: @user_id, + distribution_name: @distribution_name, component_name: @component_name) + package_file.destroy! + end + + private + + def package_file + ::Packages::PackageFile.find_by_id(@package_file_id) + end + strong_memoize_attr :package_file + + def user + ::User.find_by_id(@user_id) + end + strong_memoize_attr :user + end + end +end diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 329ccfc6362..f95176da252 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -142,12 +142,16 @@ class PostReceive def emit_snowplow_event(project, user) return unless Feature.enabled?(:route_hll_to_snowplow_phase2, project.namespace) + metric_path = 'counts.source_code_pushes' Gitlab::Tracking.event( 'PostReceive', - 'source_code_pushes', + :push, project: project, namespace: project.namespace, - user: user + user: user, + property: 'source_code_pushes', + label: metric_path, + context: [Gitlab::Tracking::ServicePingContext.new(data_source: :redis, key_path: metric_path).to_context] ) end end diff --git a/app/workers/projects/delete_branch_worker.rb b/app/workers/projects/delete_branch_worker.rb new file mode 100644 index 00000000000..1949fb67e83 --- /dev/null +++ b/app/workers/projects/delete_branch_worker.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Projects + class DeleteBranchWorker + include ApplicationWorker + + data_consistency :always + + feature_category :source_code_management + urgency :high + idempotent! + + def perform(project_id, user_id, branch_name) + project = Project.find_by_id(project_id) + user = User.find_by_id(user_id) + + return unless project.present? && user.present? + return unless project.repository.branch_exists?(branch_name) + + delete_service_result = ::Branches::DeleteService.new(project, user) + .execute(branch_name) + + return unless Feature.enabled?(:track_and_raise_delete_source_errors, project) + # Only want to raise on 400 to avoid permission and non existant branch error + return unless delete_service_result[:http_status] == 400 + + delete_service_result.track_and_raise_exception + end + end +end diff --git a/app/workers/projects/import_export/parallel_project_export_worker.rb b/app/workers/projects/import_export/parallel_project_export_worker.rb new file mode 100644 index 00000000000..ba4194fd4bc --- /dev/null +++ b/app/workers/projects/import_export/parallel_project_export_worker.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Projects + module ImportExport + class ParallelProjectExportWorker + include ApplicationWorker + include ExceptionBacktrace + + idempotent! + data_consistency :always + deduplicate :until_executed + feature_category :importers + worker_resource_boundary :memory + urgency :low + loggable_arguments 1, 2 + sidekiq_options retries: 3, dead: false, status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION + + sidekiq_retries_exhausted do |job, exception| + export_job = ProjectExportJob.find(job['args'].first) + + export_job.fail_op! + project = export_job.project + + log_payload = { + message: 'Parallel project export error', + export_error: job['error_message'], + project_export_job_id: export_job.id, + project_name: project.name, + project_id: project.id + } + Gitlab::ExceptionLogFormatter.format!(exception, log_payload) + Gitlab::Export::Logger.error(log_payload) + end + + def perform(project_export_job_id, user_id, after_export_strategy = {}) + export_job = ProjectExportJob.find(project_export_job_id) + + return if export_job.finished? + + export_job.update_attribute(:jid, jid) + current_user = User.find(user_id) + after_export = build!(after_export_strategy) + + export_service = ::Projects::ImportExport::ParallelExportService.new(export_job, current_user, after_export) + export_service.execute + + export_job.finish! + rescue Gitlab::ImportExport::AfterExportStrategyBuilder::StrategyNotFoundError + export_job.fail_op! + end + + private + + def build!(after_export_strategy) + strategy_klass = after_export_strategy&.delete('klass') + + Gitlab::ImportExport::AfterExportStrategyBuilder.build!(strategy_klass, after_export_strategy) + end + end + end +end diff --git a/app/workers/projects/inactive_projects_deletion_cron_worker.rb b/app/workers/projects/inactive_projects_deletion_cron_worker.rb index af62efeb089..31fdb3d9615 100644 --- a/app/workers/projects/inactive_projects_deletion_cron_worker.rb +++ b/app/workers/projects/inactive_projects_deletion_cron_worker.rb @@ -22,9 +22,9 @@ module Projects return unless ::Gitlab::CurrentSettings.delete_inactive_projects? @start_time ||= ::Gitlab::Metrics::System.monotonic_time - admin_user = User.admins.active.first + admin_bot = ::User.admin_bot - return unless admin_user + return unless admin_bot notified_inactive_projects = Gitlab::InactiveProjectsDeletionWarningTracker.notified_projects @@ -39,14 +39,14 @@ module Projects raise TimeoutError end - with_context(project: project, user: admin_user) do + with_context(project: project, user: admin_bot) do deletion_warning_email_sent_on = notified_inactive_projects["project:#{project.id}"] if send_deletion_warning_email?(deletion_warning_email_sent_on, project) - send_notification(project, admin_user) + send_notification(project, admin_bot) elsif deletion_warning_email_sent_on && delete_due_to_inactivity?(deletion_warning_email_sent_on) Gitlab::InactiveProjectsDeletionWarningTracker.new(project.id).reset - delete_project(project, admin_user) + delete_project(project, admin_bot) end end end diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb index 8974ddce47b..f31f006eec1 100644 --- a/app/workers/run_pipeline_schedule_worker.rb +++ b/app/workers/run_pipeline_schedule_worker.rb @@ -10,6 +10,8 @@ class RunPipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker queue_namespace :pipeline_creation feature_category :continuous_integration + deduplicate :until_executed + idempotent! def perform(schedule_id, user_id) schedule = Ci::PipelineSchedule.find_by_id(schedule_id) diff --git a/app/workers/update_highest_role_worker.rb b/app/workers/update_highest_role_worker.rb index a05c9c7a1e7..dccf88e1b1a 100644 --- a/app/workers/update_highest_role_worker.rb +++ b/app/workers/update_highest_role_worker.rb @@ -7,7 +7,7 @@ class UpdateHighestRoleWorker sidekiq_options retry: 3 - feature_category :subscription_usage_reports + feature_category :subscription_cost_management urgency :high weight 2 |