diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-10-19 15:57:54 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-10-19 15:57:54 +0300 |
commit | 419c53ec62de6e97a517abd5fdd4cbde3a942a34 (patch) | |
tree | 1f43a548b46bca8a5fb8fe0c31cef1883d49c5b6 /app | |
parent | 1da20d9135b3ad9e75e65b028bffc921aaf8deb7 (diff) |
Add latest changes from gitlab-org/gitlab@16-5-stable-eev16.5.0-rc42
Diffstat (limited to 'app')
1371 files changed, 13768 insertions, 9343 deletions
diff --git a/app/assets/images/callouts/rich_text_editor_illustration.svg b/app/assets/images/callouts/rich_text_editor_illustration.svg deleted file mode 100644 index b07d8871fe6..00000000000 --- a/app/assets/images/callouts/rich_text_editor_illustration.svg +++ /dev/null @@ -1,79 +0,0 @@ -<svg width="280" height="130" viewBox="0 0 280 130" fill="none" xmlns="http://www.w3.org/2000/svg"> -<g clip-path="url(#clip0_191_42179)"> -<circle cx="189.5" cy="-42.5" r="131.5" fill="url(#paint0_radial_191_42179)"/> -<circle cx="-41.5" cy="-97.5" r="198.5" fill="url(#paint1_radial_191_42179)"/> -<circle cx="309.5" cy="-7.5" r="121.5" fill="url(#paint2_radial_191_42179)"/> -<g filter="url(#filter0_b_191_42179)"> -<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> -<g transform="translate(64, 16)"> -<path d="M135.455 109.089H47.0349C30.7979 109.089 17.6364 95.8523 17.6364 79.5229V0H106.056C122.293 0 135.455 13.2364 135.455 29.5658V109.091V109.089Z" fill="white"/> -<path d="M37.0022 29H116C116 46 116 63 116 80C116 84.4183 112.549 88 108.293 88L37 88V29H37.0022Z" fill="white"/> -<path d="M116 16H37V29H116V16Z" fill="#AEA5D6"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M36 15H101V16H37V42H36V15Z" fill="#171321"/> -<path d="M53 22.5C53 23.8807 51.8807 25 50.5 25C49.1193 25 48 23.8807 48 22.5C48 21.1193 49.1193 20 50.5 20C51.8807 20 53 21.1193 53 22.5Z" fill="#A888F4"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M50.5 24C51.3284 24 52 23.3284 52 22.5C52 21.6716 51.3284 21 50.5 21C49.6716 21 49 21.6716 49 22.5C49 23.3284 49.6716 24 50.5 24ZM50.5 25C51.8807 25 53 23.8807 53 22.5C53 21.1193 51.8807 20 50.5 20C49.1193 20 48 21.1193 48 22.5C48 23.8807 49.1193 25 50.5 25Z" fill="#171321"/> -<path d="M60 22.5C60 23.8807 58.8807 25 57.5 25C56.1193 25 55 23.8807 55 22.5C55 21.1193 56.1193 20 57.5 20C58.8807 20 60 21.1193 60 22.5Z" fill="#FF9D73"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M57.5 24C58.3284 24 59 23.3284 59 22.5C59 21.6716 58.3284 21 57.5 21C56.6716 21 56 21.6716 56 22.5C56 23.3284 56.6716 24 57.5 24ZM57.5 25C58.8807 25 60 23.8807 60 22.5C60 21.1193 58.8807 20 57.5 20C56.1193 20 55 21.1193 55 22.5C55 23.8807 56.1193 25 57.5 25Z" fill="#171321"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M22.5 10.5C30.3325 10.5 38.152 14.4668 42.923 22.3723L43 22.5L42.923 22.6277C38.152 30.5332 30.3325 34.5 22.5 34.5C14.6675 34.5 6.84799 30.5332 2.07704 22.6277L2 22.5L2.07704 22.3723C6.84799 14.4668 14.6675 10.5 22.5 10.5Z" fill="white"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M41.8274 22.5C37.2212 15.1579 29.8576 11.5 22.5 11.5C15.1424 11.5 7.77878 15.1579 3.1726 22.5C7.77878 29.8421 15.1424 33.5 22.5 33.5C29.8576 33.5 37.2212 29.8421 41.8274 22.5ZM2 22.5L2.07704 22.6277C6.84799 30.5332 14.6675 34.5 22.5 34.5C30.3325 34.5 38.152 30.5332 42.923 22.6277L43 22.5L42.923 22.3723C38.152 14.4668 30.3325 10.5 22.5 10.5C14.6675 10.5 6.84799 14.4668 2.07704 22.3723L2 22.5Z" fill="#171321"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M0 22.5C0 21.3954 0.895434 20.5 2 20.5C3.10457 20.5 4 21.3954 4 22.5C4 23.6046 3.10457 24.5 2 24.5C0.895434 24.5 0 23.6046 0 22.5Z" fill="white"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M2 21.5C1.44772 21.5 1 21.9477 1 22.5C1 23.0523 1.44772 23.5 2 23.5C2.55229 23.5 3 23.0523 3 22.5C3 21.9477 2.55229 21.5 2 21.5ZM2 20.5C0.895434 20.5 0 21.3954 0 22.5C0 23.6046 0.895434 24.5 2 24.5C3.10457 24.5 4 23.6046 4 22.5C4 21.3954 3.10457 20.5 2 20.5Z" fill="#171321"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M41 22.5C41 21.3954 41.8954 20.5 43 20.5C44.1046 20.5 45 21.3954 45 22.5C45 23.6046 44.1046 24.5 43 24.5C41.8954 24.5 41 23.6046 41 22.5Z" fill="white"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M43 21.5C42.4477 21.5 42 21.9477 42 22.5C42 23.0523 42.4477 23.5 43 23.5C43.5523 23.5 44 23.0523 44 22.5C44 21.9477 43.5523 21.5 43 21.5ZM43 20.5C41.8954 20.5 41 21.3954 41 22.5C41 23.6046 41.8954 24.5 43 24.5C44.1046 24.5 45 23.6046 45 22.5C45 21.3954 44.1046 20.5 43 20.5Z" fill="#171321"/> -<path d="M22.5 30C26.6421 30 30 26.6421 30 22.5C30 18.3579 26.6421 15 22.5 15C18.3579 15 15 18.3579 15 22.5C15 26.6421 18.3579 30 22.5 30Z" fill="#10B1B1"/> -<path d="M27.0838 22.3192C27.0838 23.5746 25.3096 22.4248 23.8629 20.9715C22.4317 19.5337 21.3192 17.7969 22.5614 17.7969C25.0589 17.7969 27.0838 19.8217 27.0838 22.3192Z" fill="white"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M37 34V65H36V34H37Z" fill="#171321"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M37 93V70.0117H36V94H57V93H37Z" fill="#171321"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M64 93H93V94H64V93Z" fill="#171321"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M116 65V38H117V65H116Z" fill="#171321"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M144 104H122V103H144V104Z" fill="#AEA5D6"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M138 97H129V96H138V97Z" fill="#AEA5D6"/> -<path d="M104 34H47V46H104V34Z" fill="#E7E4F2"/> -<path d="M74 51H48V83H74V51Z" fill="#FF9D73"/> -<path d="M60.5 70C61.8807 70 63 68.8807 63 67.5C63 66.1193 61.8807 65 60.5 65C59.1193 65 58 66.1193 58 67.5C58 68.8807 59.1193 70 60.5 70Z" fill="white"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M60.5 69C61.3284 69 62 68.3284 62 67.5C62 66.6716 61.3284 66 60.5 66C59.6716 66 59 66.6716 59 67.5C59 68.3284 59.6716 69 60.5 69ZM63 67.5C63 68.8807 61.8807 70 60.5 70C59.1193 70 58 68.8807 58 67.5C58 66.1193 59.1193 65 60.5 65C61.8807 65 63 66.1193 63 67.5Z" fill="#171321"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M74 50V84H73V50H74Z" fill="#171321"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M47 84V70H48V84H47Z" fill="#171321"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M47 65V50H48V65H47Z" fill="#171321"/> -<path d="M104 51H78V71H104V51Z" fill="#E7E4F2"/> -<path d="M104 76H78V83H104V76Z" fill="#E7E4F2"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M23 34V58.5C23 63.1944 26.8056 67 31.5 67H59V68H31.5C26.2533 68 22 63.7467 22 58.5V34H23Z" fill="#171321"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M46 51H75V52H46V51Z" fill="#171321"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M75 83H46V82H75V83Z" fill="#171321"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M14 67H32V68H14V67Z" fill="#171321"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M2 67H10V68H2V67Z" fill="#AEA5D6"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M58.5 69.5V95.5C58.5 100.194 62.3056 104 67 104H94L93.5 105H67C61.7533 105 57.5 100.747 57.5 95.5V69.5H58.5Z" fill="#171321"/> -<rect x="130.598" y="54.4473" width="19" height="46" transform="rotate(45 130.598 54.4473)" fill="white"/> -<path d="M111.506 100.41L98.0714 86.9746L93.4752 105.006L111.506 100.41Z" fill="#FF9D73"/> -<path d="M140.498 44.5479L144.033 48.0834C146.666 50.7156 147.982 52.0318 148.701 53.443C150.154 56.2951 150.154 59.6706 148.701 62.5228C147.982 63.934 146.666 65.2501 144.033 67.8824L144.033 67.8824L130.598 54.4473L140.498 44.5479Z" fill="#5829CB"/> -<path d="M130.598 54.4473L131.305 55.1544L98.7785 87.6813L98.0714 86.9742L130.598 54.4473Z" fill="#171321"/> -<path d="M143.326 67.1758L144.033 67.8829L111.506 100.41L110.799 99.7027L143.326 67.1758Z" fill="#171321"/> -<path d="M136.962 60.8115L137.669 61.5186L105.142 94.0455L104.435 93.3384L136.962 60.8115Z" fill="#171321"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M95.4861 97.1172L93.4752 105.006L101.364 102.995L95.4861 97.1172Z" fill="#171321"/> -</g> -<defs> -<filter id="filter0_b_191_42179" 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_191_42179"/> -<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_191_42179" result="shape"/> -</filter> -<radialGradient id="paint0_radial_191_42179" 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_191_42179" 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_191_42179" 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_191_42179"> -<path d="M0 4C0 1.79086 1.79086 0 4 0H276C278.209 0 280 1.79086 280 4V130H0V4Z" fill="white"/> -</clipPath> -</defs> -</svg> diff --git a/app/assets/images/jobs-empty-state.svg b/app/assets/images/jobs-empty-state.svg deleted file mode 100644 index e6e0681a002..00000000000 --- a/app/assets/images/jobs-empty-state.svg +++ /dev/null @@ -1,33 +0,0 @@ -<svg width="234" height="162" viewBox="0 0 234 162" fill="none" xmlns="http://www.w3.org/2000/svg"> -<path d="M174.68 56.344H200.5C215.412 56.344 227.5 44.1787 227.5 29.172C227.5 14.1653 215.412 2 200.5 2C185.588 2 173.5 14.1653 173.5 29.172C173.5 36.2548 176.193 42.7046 180.604 47.5412" stroke="#C2B7E6" stroke-width="4" stroke-linecap="round"/> -<path d="M145.5 76.4714C145.5 65.3553 154.454 56.344 165.5 56.344" stroke="#C2B7E6" stroke-width="4" stroke-linecap="round"/> -<path d="M102.5 121.758H29.5C14.5883 121.758 2.5 109.593 2.5 94.586C2.5 79.5794 14.5883 67.4141 29.5 67.4141C44.4117 67.4141 56.5 79.5794 56.5 94.586C56.5 101.669 53.8072 108.119 49.3957 112.955" stroke="#C2B7E6" stroke-width="4" stroke-linecap="round"/> -<path d="M67.0466 121.758H52.5C42.5589 121.758 34.5 129.868 34.5 139.873C34.5 149.877 42.5589 157.987 52.5 157.987C62.4411 157.987 70.5 149.877 70.5 139.873C70.5 137.478 70.0384 135.192 69.1998 133.1" stroke="#C2B7E6" stroke-width="4" stroke-linecap="round"/> -<g clip-path="url(#clip0)"> -<path d="M55.0188 135.3C55.1617 134.764 54.8451 134.211 54.3117 134.068C53.7782 133.925 53.2298 134.243 53.0869 134.78L49.9811 146.445C49.8381 146.981 50.1547 147.534 50.6882 147.677C51.2217 147.821 51.77 147.503 51.9129 146.965L55.0188 135.3Z" fill="#FC6D26"/> -<path d="M49.2071 137.142C49.5976 137.534 49.5976 138.172 49.2071 138.565L46.9142 140.873L49.2071 143.18C49.5976 143.573 49.5976 144.211 49.2071 144.603C48.8166 144.997 48.1834 144.997 47.7929 144.603L44.7929 141.584C44.4024 141.192 44.4024 140.554 44.7929 140.161L47.7929 137.142C48.1834 136.748 48.8166 136.748 49.2071 137.142Z" fill="#FC6D26"/> -<path d="M55.7929 137.142C55.4024 137.534 55.4024 138.172 55.7929 138.565L58.0858 140.873L55.7929 143.18C55.4024 143.573 55.4024 144.211 55.7929 144.603C56.1834 144.997 56.8166 144.997 57.2071 144.603L60.2071 141.584C60.5976 141.192 60.5976 140.554 60.2071 140.161L57.2071 137.142C56.8166 136.748 56.1834 136.748 55.7929 137.142Z" fill="#FC6D26"/> -</g> -<path d="M212.102 160C222.815 160 231.5 151.214 231.5 140.376C231.5 129.537 222.815 120.752 212.102 120.752H151.5" stroke="#C2B7E6" stroke-width="4" stroke-linecap="round"/> -<path d="M126.5 138.866C107.171 138.866 91.5 123.096 91.5 103.643C91.5 84.191 107.171 68.4204 126.5 68.4204C145.829 68.4204 161.5 84.191 161.5 103.643C161.5 123.096 145.829 138.866 126.5 138.866ZM126.5 131.451C141.76 131.451 154.132 119.001 154.132 103.643C154.132 88.2861 141.76 75.8358 126.5 75.8358C111.24 75.8358 98.8684 88.2861 98.8684 103.643C98.8684 119.001 111.24 131.451 126.5 131.451Z" fill="#FC6D26"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M126.126 87.1326C135.355 87.1326 142.906 94.5624 142.906 103.643C142.906 112.724 135.355 120.154 126.126 120.154C120.672 120.154 115.638 117.265 112.281 113.137L126.126 103.643V87.1326Z" fill="#6E49CB"/> -<g clip-path="url(#clip1)"> -<path d="M29.5 90.2659L24.3571 91.9534V93.1629C24.3571 94.9623 25.087 96.6872 26.3846 97.9546L29.5 100.997V90.2659Z" fill="#FC6D26"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M17.5 86.8909L29.5 83.5159L41.5 86.8909V93.1115C41.5 96.6919 40.0551 100.126 37.4832 102.657L29.5 110.516L21.5168 102.657C18.9449 100.126 17.5 96.6919 17.5 93.1115V86.8909ZM20.9286 93.1115V89.4366L29.5 87.0259L38.0714 89.4366V93.1115C38.0714 95.7968 36.9878 98.3721 35.0588 100.271L29.5 105.743L23.9412 100.271C22.0122 98.3721 20.9286 95.7968 20.9286 93.1115Z" fill="#FC6D26"/> -</g> -<g clip-path="url(#clip2)"> -<path d="M210.857 19.7297L209.51 24.8237C208.922 27.0445 207.518 28.9576 205.581 30.1752L194.728 36.999L191.862 34.1146L198.642 23.1922C199.852 21.2431 201.753 19.8298 203.96 19.2386L209.022 17.8826C209.822 17.6681 210.644 18.1474 210.857 18.953C210.925 19.2075 210.925 19.4752 210.857 19.7297ZM207.292 21.4702L204.732 22.1561C203.261 22.5503 201.993 23.4925 201.187 24.7918L196.517 32.3146L203.992 27.6148C205.283 26.803 206.219 25.5276 206.611 24.0471L207.292 21.4702ZM196.5 38.2294L204 33.7007V35.2103C204 38.5451 201.314 41.2485 198 41.2485H196.5V38.2294ZM190.5 32.1912H187.5V30.6816C187.5 27.3468 190.186 24.6434 193.5 24.6434H195L190.5 32.1912Z" fill="#FC6D26"/> -</g> -<path fill-rule="evenodd" clip-rule="evenodd" d="M209.914 132.822C209.384 132.822 208.875 133.032 208.5 133.407L204.796 137.111C204.613 137.293 204.5 137.544 204.5 137.822V144.822C204.5 145.926 205.395 146.822 206.5 146.822H216.5C217.605 146.822 218.5 145.926 218.5 144.822V137.822C218.5 137.546 218.388 137.296 218.207 137.115L214.5 133.407C214.125 133.032 213.616 132.822 213.086 132.822H209.914ZM215.086 136.822L213.086 134.822H212.5V136.822H215.086ZM210.5 134.822H209.914L207.914 136.822H210.5V134.822ZM206.5 138.822H216.5V144.822H206.5V138.822Z" fill="#FC6D26"/> -<defs> -<clipPath id="clip0"> -<rect width="16" height="13.6779" fill="white" transform="translate(44.5 134.033)"/> -</clipPath> -<clipPath id="clip1"> -<rect width="24" height="27.172" fill="white" transform="translate(17.5 83.5159)"/> -</clipPath> -<clipPath id="clip2"> -<rect width="24" height="24.1529" fill="white" transform="translate(187.5 17.0956)"/> -</clipPath> -</defs> -</svg> diff --git a/app/assets/javascripts/admin/abuse_report/components/report_actions.vue b/app/assets/javascripts/admin/abuse_report/components/report_actions.vue index 560d733c10c..e005e183c9f 100644 --- a/app/assets/javascripts/admin/abuse_report/components/report_actions.vue +++ b/app/assets/javascripts/admin/abuse_report/components/report_actions.vue @@ -14,8 +14,10 @@ import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; import { ACTIONS_I18N, NO_ACTION, + TRUST_ACTION, USER_ACTION_OPTIONS, REASON_OPTIONS, + TRUST_REASON, STATUS_OPEN, SUCCESS_ALERT, FAILED_ALERT, @@ -77,6 +79,16 @@ export default { userActionOptions() { return this.isNotCurrentUser ? USER_ACTION_OPTIONS : [NO_ACTION]; }, + reasonOptions() { + if (!this.isNotCurrentUser) { + return []; + } + + if (this.form.user_action === TRUST_ACTION.value) { + return [TRUST_REASON]; + } + return REASON_OPTIONS; + }, }, methods: { toggleActionsDrawer() { @@ -120,7 +132,6 @@ export default { }, }, i18n: ACTIONS_I18N, - reasonOptions: REASON_OPTIONS, DRAWER_Z_INDEX, }; </script> @@ -173,7 +184,7 @@ export default { id="reason" v-model="form.reason" data-testid="reason-select" - :options="$options.reasonOptions" + :options="reasonOptions" :state="validationState.reason" @change="validateReason" /> diff --git a/app/assets/javascripts/admin/abuse_report/components/user_details.vue b/app/assets/javascripts/admin/abuse_report/components/user_details.vue index fe0add1ba8d..0c32341652b 100644 --- a/app/assets/javascripts/admin/abuse_report/components/user_details.vue +++ b/app/assets/javascripts/admin/abuse_report/components/user_details.vue @@ -60,9 +60,6 @@ export default { data-testid="credit-card-verification" :label="$options.i18n.creditCard" > - <gl-sprintf :message="$options.i18n.registeredWith"> - <template #name>{{ user.creditCard.name }}</template> - </gl-sprintf> <gl-sprintf v-if="showSimilarRecords" :message="$options.i18n.similarRecords"> <template #cardMatchesLink="{ content }"> <gl-link :href="user.creditCard.cardMatchesLink"> diff --git a/app/assets/javascripts/admin/abuse_report/constants.js b/app/assets/javascripts/admin/abuse_report/constants.js index 6cae6b24f20..f028408bed7 100644 --- a/app/assets/javascripts/admin/abuse_report/constants.js +++ b/app/assets/javascripts/admin/abuse_report/constants.js @@ -25,11 +25,14 @@ export const ACTIONS_I18N = { }; export const NO_ACTION = { value: '', text: s__('AbuseReport|No action') }; +export const TRUST_REASON = { value: 'trusted', text: s__(`AbuseReport|Confirmed trusted user`) }; +export const TRUST_ACTION = { value: 'trust_user', text: s__('AbuseReport|Trust user') }; export const USER_ACTION_OPTIONS = [ NO_ACTION, { value: 'block_user', text: s__('AbuseReport|Block user') }, { value: 'ban_user', text: s__('AbuseReport|Ban user') }, + TRUST_ACTION, { value: 'delete_user', text: s__('AbuseReport|Delete user') }, ]; @@ -75,7 +78,6 @@ export const USER_DETAILS_I18N = { reportedFor: s__( 'AbuseReport|%{reportLinkStart}Reported%{reportLinkEnd} for %{category} %{timeAgo}.', ), - registeredWith: s__('AbuseReport|Registered with name %{name}.'), similarRecords: s__( 'AbuseReport|Card matches %{cardMatchesLinkStart}%{count} accounts%{cardMatchesLinkEnd}', ), @@ -87,6 +89,7 @@ export const REPORTED_CONTENT_I18N = { comment: s__('AbuseReport|Reported comment'), issue: s__('AbuseReport|Reported issue'), merge_request: s__('AbuseReport|Reported merge request'), + epic: s__('AbuseReport|Reported epic'), unknown: s__('AbuseReport|Reported content'), }, viewScreenshot: s__('AbuseReport|View screenshot'), @@ -96,6 +99,7 @@ export const REPORTED_CONTENT_I18N = { comment: s__('AbuseReport|Go to comment'), issue: s__('AbuseReport|Go to issue'), merge_request: s__('AbuseReport|Go to merge request'), + epic: s__('AbuseReport|Go to epic'), unknown: s__('AbuseReport|Go to content'), }, reportedBy: s__('AbuseReport|Reported by'), diff --git a/app/assets/javascripts/admin/application_settings/inactive_project_deletion/components/form.vue b/app/assets/javascripts/admin/application_settings/inactive_project_deletion/components/form.vue index ef4a5319eec..0b640a34864 100644 --- a/app/assets/javascripts/admin/application_settings/inactive_project_deletion/components/form.vue +++ b/app/assets/javascripts/admin/application_settings/inactive_project_deletion/components/form.vue @@ -143,7 +143,7 @@ export default { v-model="minSizeMb" :state="isMinSizeMbValid" name="application_setting[inactive_projects_min_size_mb]" - size="md" + width="md" type="number" :min="0" data-testid="min-size-input" @@ -177,7 +177,7 @@ export default { v-model="deleteAfterMonths" :state="isDeleteAfterMonthsValid" name="application_setting[inactive_projects_delete_after_months]" - size="sm" + width="sm" type="number" :min="0" data-testid="delete-after-months-input" @@ -215,7 +215,7 @@ export default { v-model="sendWarningEmailAfterMonths" :state="isSendWarningEmailAfterMonthsValid" name="application_setting[inactive_projects_send_warning_email_after_months]" - size="sm" + width="sm" type="number" :min="0" data-testid="send-warning-email-after-months-input" diff --git a/app/assets/javascripts/admin/broadcast_messages/components/datetime_picker.vue b/app/assets/javascripts/admin/broadcast_messages/components/datetime_picker.vue index 07814ef2511..253eefc323c 100644 --- a/app/assets/javascripts/admin/broadcast_messages/components/datetime_picker.vue +++ b/app/assets/javascripts/admin/broadcast_messages/components/datetime_picker.vue @@ -42,6 +42,6 @@ export default { <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" /> + <gl-form-input v-model="time" width="sm" type="time" data-testid="time-picker" /> </div> </template> diff --git a/app/assets/javascripts/alert.js b/app/assets/javascripts/alert.js index 006c4f50d09..4d724b17723 100644 --- a/app/assets/javascripts/alert.js +++ b/app/assets/javascripts/alert.js @@ -1,6 +1,7 @@ import * as Sentry from '@sentry/browser'; import Vue from 'vue'; -import { GlAlert } from '@gitlab/ui'; +import isEmpty from 'lodash/isEmpty'; +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; import { __ } from '~/locale'; export const VARIANT_SUCCESS = 'success'; @@ -32,6 +33,14 @@ export const VARIANT_TIP = 'tip'; * // Respond to the alert being dismissed * createAlert({ message: 'Message', onDismiss: () => {} }); * + * @example + * // Add inline link in the message + * createAlert({ message: 'Read more at %{exampleLinkStart}example page%{exampleLinkEnd}.', messageLinks: { exampleLink: 'https://example.com' } }); + * + * @example + * // Add inline links in the message with custom GlLink props + * createAlert({ message: 'Read more at %{exampleLinkStart}example page%{exampleLinkEnd}.', messageLinks: { exampleLink: { href: 'https://example.com', target: '_blank', isUnsafeLink: true }} }); + * * @param {object} options - Options to control the flash message * @param {string} options.message - Alert message text * @param {string} [options.title] - Alert title @@ -48,6 +57,7 @@ export const VARIANT_TIP = 'tip'; * @param {string} [options.secondaryButton.link] - Href of secondary button * @param {string} [options.secondaryButton.text] - Text of secondary button * @param {Function} [options.secondaryButton.clickHandler] - Handler to call when secondary button is clicked on. The click event is sent as an argument. + * @param {object} [options.messageLinks] - Object containing mapping of sprintf tokens to URLs, used to format links within the message. If needed, you can pass a full props object for GlLink instead of a URL string * @param {boolean} [options.captureError] - Whether to send error to Sentry * @param {object} [options.error] - Error to be captured in Sentry */ @@ -63,6 +73,7 @@ export const createAlert = ({ onDismiss = null, captureError = false, error = null, + messageLinks = null, }) => { if (captureError && error) Sentry.captureException(error); @@ -76,6 +87,45 @@ export const createAlert = ({ alertContainer.replaceChildren(el); } + const createMessageNodes = (h) => { + if (isEmpty(messageLinks)) { + return message; + } + + const normalizeLinkProps = (hrefOrProps) => { + const { href, ...otherLinkProps } = + typeof hrefOrProps === 'string' ? { href: hrefOrProps } : hrefOrProps; + + return { href, linkProps: otherLinkProps }; + }; + + return [ + h(GlSprintf, { + props: { + message, + }, + scopedSlots: Object.assign( + {}, + ...Object.entries(messageLinks).map(([slotName, hrefOrProps]) => { + const { href, linkProps } = normalizeLinkProps(hrefOrProps); + + return { + [slotName]: (props) => + h( + GlLink, + { + props: linkProps, + attrs: { href }, + }, + props.content, + ), + }; + }), + ), + }), + ]; + }; + return new Vue({ el, components: { @@ -130,7 +180,7 @@ export const createAlert = ({ }, on, }, - message, + createMessageNodes(h), ); }, }); diff --git a/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue b/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue index 77c14d9f812..da9f300c941 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue @@ -34,7 +34,11 @@ export default { </script> <template> <div> - <gl-empty-state :title="$options.i18n.emptyState.title" :svg-path="emptyAlertSvgPath"> + <gl-empty-state + :title="$options.i18n.emptyState.title" + :svg-path="emptyAlertSvgPath" + :svg-height="null" + > <template #description> <div class="gl-display-block"> <span>{{ $options.i18n.emptyState.info }}</span> diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/base.vue b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue index 84ee8f41b11..39fbc217278 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/components/base.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue @@ -56,6 +56,7 @@ export default { 'hasNoAccessError', 'groupPath', 'namespace', + 'predefinedDateRange', ]), ...mapGetters(['pathNavigationData', 'filterParams']), isLoaded() { @@ -132,6 +133,7 @@ export default { 'fetchStageData', 'setSelectedStage', 'setDateRange', + 'setPredefinedDateRange', 'updateStageTablePagination', ]), onSetDateRange({ startDate, endDate }) { @@ -170,7 +172,9 @@ export default { :start-date="createdAfter" :end-date="createdBefore" :group-path="groupPath" + :predefined-date-range="predefinedDateRange" @setDateRange="onSetDateRange" + @setPredefinedDateRange="setPredefinedDateRange" /> <div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row"> <path-navigation diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue index 38f9936c7c1..898633868cd 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue @@ -195,6 +195,7 @@ export default { :title="emptyStateTitleText" :description="emptyStateMessage" :svg-path="noDataSvgPath" + :svg-height="null" /> <gl-table v-else diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue b/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue index 0de62013a63..775c3827fc7 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue @@ -2,7 +2,17 @@ import { GlTooltipDirective } from '@gitlab/ui'; import DateRange from '~/analytics/shared/components/daterange.vue'; import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue'; -import { DATE_RANGE_LIMIT, PROJECTS_PER_PAGE } from '~/analytics/shared/constants'; +import { + DATE_RANGE_LIMIT, + DATE_RANGE_CUSTOM_VALUE, + PROJECTS_PER_PAGE, + MAX_DATE_RANGE_TEXT, + DATE_RANGE_LAST_30_DAYS_VALUE, + LAST_30_DAYS, +} from '~/analytics/shared/constants'; +import { getCurrentUtcDate, datesMatch } from '~/lib/utils/datetime_utility'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import DateRangesDropdown from '~/analytics/shared/components/date_ranges_dropdown.vue'; import FilterBar from './filter_bar.vue'; export default { @@ -11,10 +21,12 @@ export default { DateRange, ProjectsDropdownFilter, FilterBar, + DateRangesDropdown, }, directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagsMixin()], props: { selectedProjects: { type: Array, @@ -31,6 +43,11 @@ export default { required: false, default: true, }, + hasPredefinedDateRangesFilter: { + type: Boolean, + required: false, + default: true, + }, namespacePath: { type: String, required: true, @@ -49,6 +66,11 @@ export default { required: false, default: null, }, + predefinedDateRange: { + type: String, + required: false, + default: null, + }, }, computed: { projectsQueryParams() { @@ -58,42 +80,104 @@ export default { }; }, currentDate() { - const now = new Date(); - return new Date(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + return getCurrentUtcDate(); + }, + isDefaultDateRange() { + return datesMatch(this.startDate, LAST_30_DAYS) && datesMatch(this.endDate, this.currentDate); + }, + supportsPredefinedDateRanges() { + return this.glFeatures?.vsaPredefinedDateRanges; + }, + dateRangeOption() { + const { predefinedDateRange } = this; + + if (predefinedDateRange) return predefinedDateRange; + + if (!predefinedDateRange && !this.isDefaultDateRange) return DATE_RANGE_CUSTOM_VALUE; + + return DATE_RANGE_LAST_30_DAYS_VALUE; + }, + isCustomDateRangeSelected() { + return this.dateRangeOption === DATE_RANGE_CUSTOM_VALUE; + }, + shouldShowPredefinedDateRanges() { + return this.supportsPredefinedDateRanges && this.hasPredefinedDateRangesFilter; + }, + shouldShowDateRangePicker() { + if (this.shouldShowPredefinedDateRanges) { + return this.hasDateRangeFilter && this.isCustomDateRangeSelected; + } + + return this.hasDateRangeFilter; + }, + maxDateRangeTooltip() { + return this.$options.i18n.maxDateRangeTooltip(this.$options.maxDateRange); + }, + shouldShowDateRangeFilters() { + return this.hasDateRangeFilter || this.hasPredefinedDateRangesFilter; + }, + shouldShowFilterDropdowns() { + return this.hasProjectFilter || this.shouldShowDateRangeFilters; + }, + }, + methods: { + onSelectPredefinedDateRange({ value, startDate, endDate }) { + this.$emit('setPredefinedDateRange', value); + this.$emit('setDateRange', { startDate, endDate }); + }, + onSelectCustomDateRange() { + this.$emit('setPredefinedDateRange', DATE_RANGE_CUSTOM_VALUE); }, }, multiProjectSelect: true, maxDateRange: DATE_RANGE_LIMIT, + i18n: { + maxDateRangeTooltip: MAX_DATE_RANGE_TEXT, + }, }; </script> <template> <div - class="gl-mt-3 gl-py-2 gl-px-3 gl-bg-gray-10 gl-border-b-1 gl-border-b-solid gl-border-t-1 gl-border-t-solid gl-border-gray-100" + class="gl-mt-3 gl-py-5 gl-px-3 gl-bg-gray-10 gl-border-b-1 gl-border-b-solid gl-border-t-1 gl-border-t-solid gl-border-gray-100" > <filter-bar data-testid="vsa-filter-bar" - class="filtered-search-box gl-display-flex gl-mb-2 gl-mr-3 gl-border-none" + class="filtered-search-box gl-display-flex gl-border-none" :namespace-path="namespacePath" /> + <hr v-if="shouldShowFilterDropdowns" class="gl-my-5" /> <div - v-if="hasDateRangeFilter || hasProjectFilter" - class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-justify-content-space-between" + v-if="shouldShowFilterDropdowns" + class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-gap-5" > - <div> - <projects-dropdown-filter - v-if="hasProjectFilter" - toggle-classes="gl-max-w-26" - class="js-projects-dropdown-filter project-select gl-mb-2 gl-lg-mb-0" - :group-namespace="groupPath" - :query-params="projectsQueryParams" - :multi-select="$options.multiProjectSelect" - :default-projects="selectedProjects" - @selected="$emit('selectProject', $event)" + <projects-dropdown-filter + v-if="hasProjectFilter" + toggle-classes="gl-max-w-26" + class="js-projects-dropdown-filter project-select" + :group-namespace="groupPath" + :query-params="projectsQueryParams" + :multi-select="$options.multiProjectSelect" + :default-projects="selectedProjects" + @selected="$emit('selectProject', $event)" + /> + <div + v-if="shouldShowDateRangeFilters" + class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-gap-3" + data-testid="vsa-date-range-filter-container" + > + <date-ranges-dropdown + v-if="shouldShowPredefinedDateRanges" + data-testid="vsa-predefined-date-ranges-dropdown" + :selected="dateRangeOption" + :tooltip="maxDateRangeTooltip" + include-end-date-in-days-selected + :include-custom-date-range-option="hasDateRangeFilter" + @selected="onSelectPredefinedDateRange" + @customDateRangeSelected="onSelectCustomDateRange" /> - </div> - <div class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row"> <date-range - v-if="hasDateRangeFilter" + v-if="shouldShowDateRangePicker" + data-testid="vsa-date-range-picker" :start-date="startDate" :end-date="endDate" :max-date="currentDate" diff --git a/app/assets/javascripts/analytics/cycle_analytics/store/actions.js b/app/assets/javascripts/analytics/cycle_analytics/store/actions.js index 32fe0abe83e..90ac531aa87 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/store/actions.js +++ b/app/assets/javascripts/analytics/cycle_analytics/store/actions.js @@ -163,6 +163,10 @@ export const setDateRange = ({ dispatch, commit }, { createdAfter, createdBefore return dispatch('refetchStageData'); }; +export const setPredefinedDateRange = ({ commit }, predefinedDateRange) => { + commit(types.SET_PREDEFINED_DATE_RANGE, predefinedDateRange); +}; + export const setInitialStage = ({ dispatch, commit, state: { stages } }, stage) => { if (!stages.length && !stage) { commit(types.SET_NO_ACCESS_ERROR); diff --git a/app/assets/javascripts/analytics/cycle_analytics/store/mutation_types.js b/app/assets/javascripts/analytics/cycle_analytics/store/mutation_types.js index 9376d81f317..e0a7a4292e2 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/store/mutation_types.js +++ b/app/assets/javascripts/analytics/cycle_analytics/store/mutation_types.js @@ -4,6 +4,7 @@ export const SET_LOADING = 'SET_LOADING'; export const SET_SELECTED_VALUE_STREAM = 'SET_SELECTED_VALUE_STREAM'; export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE'; export const SET_DATE_RANGE = 'SET_DATE_RANGE'; +export const SET_PREDEFINED_DATE_RANGE = 'SET_PREDEFINED_DATE_RANGE'; export const SET_PAGINATION = 'SET_PAGINATION'; export const SET_NO_ACCESS_ERROR = 'SET_NO_ACCESS_ERROR'; diff --git a/app/assets/javascripts/analytics/cycle_analytics/store/mutations.js b/app/assets/javascripts/analytics/cycle_analytics/store/mutations.js index 4af96fc96e3..4fa88279fe0 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/store/mutations.js +++ b/app/assets/javascripts/analytics/cycle_analytics/store/mutations.js @@ -34,6 +34,9 @@ export default { state.createdBefore = createdBefore; state.createdAfter = createdAfter; }, + [types.SET_PREDEFINED_DATE_RANGE](state, predefinedDateRange) { + state.predefinedDateRange = predefinedDateRange; + }, [types.SET_PAGINATION](state, { page, hasNextPage, sort, direction }) { Vue.set(state, 'pagination', { page, diff --git a/app/assets/javascripts/analytics/cycle_analytics/store/state.js b/app/assets/javascripts/analytics/cycle_analytics/store/state.js index 0c51656c59f..3d9b56b043d 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/store/state.js +++ b/app/assets/javascripts/analytics/cycle_analytics/store/state.js @@ -32,4 +32,5 @@ export default () => ({ sort: PAGINATION_SORT_FIELD_END_EVENT, direction: PAGINATION_SORT_DIRECTION_DESC, }, + predefinedDateRange: null, }); diff --git a/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue b/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue index 593de1dcee7..91f0019913c 100644 --- a/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue +++ b/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue @@ -72,6 +72,7 @@ export default { v-if="isEmpty" :title="__('Data is still calculating...')" :svg-path="noDataImagePath" + :svg-height="null" > <template #description> <p class="gl-mb-0">{{ __('It may be several days before you see feature usage data.') }}</p> diff --git a/app/assets/javascripts/analytics/devops_reports/components/service_ping_disabled.vue b/app/assets/javascripts/analytics/devops_reports/components/service_ping_disabled.vue index b9501107e37..c2e1e3f1bad 100644 --- a/app/assets/javascripts/analytics/devops_reports/components/service_ping_disabled.vue +++ b/app/assets/javascripts/analytics/devops_reports/components/service_ping_disabled.vue @@ -24,7 +24,11 @@ export default { }; </script> <template> - <gl-empty-state :title="s__('ServicePing|Service ping is off')" :svg-path="svgPath"> + <gl-empty-state + :title="s__('ServicePing|Service ping is off')" + :svg-path="svgPath" + :svg-height="null" + > <template #description> <gl-sprintf v-if="!isAdmin" diff --git a/app/assets/javascripts/analytics/shared/components/date_ranges_dropdown.vue b/app/assets/javascripts/analytics/shared/components/date_ranges_dropdown.vue new file mode 100644 index 00000000000..7ea7aba6f44 --- /dev/null +++ b/app/assets/javascripts/analytics/shared/components/date_ranges_dropdown.vue @@ -0,0 +1,131 @@ +<script> +import { GlCollapsibleListbox, GlIcon, GlTooltipDirective } from '@gitlab/ui'; + +import { isString } from 'lodash'; +import { isValidDate, getDayDifference } from '~/lib/utils/datetime_utility'; +import { + DATE_RANGE_CUSTOM_VALUE, + DEFAULT_DATE_RANGE_OPTIONS, + NUMBER_OF_DAYS_SELECTED, +} from '~/analytics/shared/constants'; +import { __ } from '~/locale'; + +export default { + name: 'DateRangesDropdown', + components: { + GlCollapsibleListbox, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + dateRangeOptions: { + type: Array, + required: false, + default: () => DEFAULT_DATE_RANGE_OPTIONS, + validator: (options) => + options.length && + options.every( + ({ text, value, startDate, endDate }) => + isString(text) && + isString(value) && + isValidDate(startDate) && + isValidDate(endDate) && + endDate >= startDate, + ), + }, + selected: { + type: String, + required: false, + default: '', + }, + tooltip: { + type: String, + required: false, + default: '', + }, + includeCustomDateRangeOption: { + type: Boolean, + required: false, + default: true, + }, + includeEndDateInDaysSelected: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + selectedValue: this.selected || this.dateRangeOptions[0].value, + }; + }, + computed: { + items() { + const dateRangeOptions = this.dateRangeOptions.map(({ text, value }) => ({ text, value })); + + if (!this.includeCustomDateRangeOption) return dateRangeOptions; + + return [...dateRangeOptions, this.$options.customDateRangeItem]; + }, + isCustomDateRangeSelected() { + return this.selectedValue === DATE_RANGE_CUSTOM_VALUE; + }, + groupedDateRangeOptionsByValue() { + return this.dateRangeOptions.reduce((acc, { value, startDate, endDate }) => { + acc[value] = { startDate, endDate }; + + return acc; + }, {}); + }, + selectedDateRange() { + if (this.isCustomDateRangeSelected) return null; + + return this.groupedDateRangeOptionsByValue[this.selectedValue]; + }, + showDaysSelectedCount() { + return !this.isCustomDateRangeSelected && this.daysSelectedCount; + }, + daysSelectedCount() { + const { selectedDateRange } = this; + + if (!selectedDateRange) return ''; + + const { startDate, endDate } = selectedDateRange; + + const daysCount = getDayDifference(startDate, endDate); + + return this.$options.i18n.daysSelected( + this.includeEndDateInDaysSelected ? daysCount + 1 : daysCount, + ); + }, + }, + methods: { + onSelect(value) { + if (this.isCustomDateRangeSelected) { + this.$emit('customDateRangeSelected'); + } else { + this.$emit('selected', { value, ...this.selectedDateRange }); + } + }, + }, + customDateRangeItem: { + text: __('Custom'), + value: DATE_RANGE_CUSTOM_VALUE, + }, + i18n: { + daysSelected: NUMBER_OF_DAYS_SELECTED, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center gl-gap-3"> + <gl-collapsible-listbox v-model="selectedValue" :items="items" @select="onSelect" /> + <div v-if="showDaysSelectedCount" class="gl-text-gray-500"> + <span data-testid="predefined-date-range-days-count">{{ daysSelectedCount }}</span> + <gl-icon v-if="tooltip" v-gl-tooltip class="gl-ml-2" name="information-o" :title="tooltip" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/analytics/shared/components/daterange.vue b/app/assets/javascripts/analytics/shared/components/daterange.vue index f47e0ccbbf2..2126359cfe4 100644 --- a/app/assets/javascripts/analytics/shared/components/daterange.vue +++ b/app/assets/javascripts/analytics/shared/components/daterange.vue @@ -1,7 +1,7 @@ <!-- eslint-disable vue/multi-word-component-names --> <script> import { GlDaterangePicker } from '@gitlab/ui'; -import { n__, __, sprintf } from '~/locale'; +import { MAX_DATE_RANGE_TEXT, NUMBER_OF_DAYS_SELECTED } from '~/analytics/shared/constants'; export default { components: { @@ -46,18 +46,6 @@ export default { default: false, }, }, - data() { - return { - maxDateRangeTooltip: sprintf( - __( - 'Showing data for workflow items completed in this date range. Date range limited to %{maxDateRange} days.', - ), - { - maxDateRange: this.maxDateRange, - }, - ), - }; - }, computed: { dateRange: { get() { @@ -67,12 +55,19 @@ export default { this.$emit('change', { startDate, endDate }); }, }, + maxDateRangeTooltip() { + return this.$options.i18n.maxDateRangeTooltip(this.maxDateRange); + }, }, methods: { numberOfDays(daysSelected) { - return n__('1 day selected', '%d days selected', daysSelected); + return this.$options.i18n.daysSelected(daysSelected); }, }, + i18n: { + maxDateRangeTooltip: MAX_DATE_RANGE_TEXT, + daysSelected: NUMBER_OF_DAYS_SELECTED, + }, }; </script> <template> diff --git a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue index ddfc6baafa9..662451c5eb4 100644 --- a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue +++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue @@ -1,6 +1,6 @@ <script> import { GlButton, GlIcon, GlAvatar, GlCollapsibleListbox, GlTruncate } from '@gitlab/ui'; -import { debounce } from 'lodash'; +import { debounce, unionBy } from 'lodash'; import { filterBySearchTerm } from '~/analytics/shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; @@ -8,6 +8,8 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { n__, s__, __ } from '~/locale'; import getProjects from '../graphql/projects.query.graphql'; +const MIN_SEARCH_CHARS = 3; + const sortByProjectName = (projects = []) => projects.sort((a, b) => a.name.localeCompare(b.name)); const mapItemToListboxFormat = (item) => ({ ...item, value: item.id, text: item.name }); @@ -98,10 +100,6 @@ export default { availableProjects() { return filterBySearchTerm(this.projects, this.searchTerm); }, - noResultsAvailable() { - const { loading, availableProjects } = this; - return !loading && !availableProjects.length; - }, selectedItems() { return sortByProjectName(this.selectedProjects); }, @@ -152,6 +150,9 @@ export default { singleSelectedProject(selectedObj, isMarking) { return isMarking ? [selectedObj] : []; }, + getSelectedProjects(projects, selectedProjectIds) { + return projects.filter(({ id }) => selectedProjectIds.includes(id)); + }, setSelectedProjects(payload) { this.selectedProjects = this.multiSelect ? payload @@ -163,8 +164,10 @@ export default { this.handleUpdatedSelectedProjects(); }, onMultiSelectClick(projectIds) { - const projects = this.availableProjects.filter(({ id }) => projectIds.includes(id)); - this.setSelectedProjects(projects); + const newlySelectedProjects = this.getSelectedProjects(this.availableProjects, projectIds); + const selectedProjects = this.getSelectedProjects(this.selectedProjects, projectIds); + + this.setSelectedProjects(unionBy(newlySelectedProjects, selectedProjects, 'id')); this.isDirty = true; }, onSelected(payload) { @@ -219,7 +222,12 @@ export default { return getIdFromGraphQLId(project.id); }, setSearchTerm(val) { - this.searchTerm = val; + if (val && val.length >= MIN_SEARCH_CHARS) { + this.searchTerm = val; + return; + } + + this.searchTerm = ''; }, }, AVATAR_SHAPE_OPTION_RECT, diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js index 7ec7eac24ec..f0d9bf201e5 100644 --- a/app/assets/javascripts/analytics/shared/constants.js +++ b/app/assets/javascripts/analytics/shared/constants.js @@ -1,9 +1,17 @@ import dateFormat, { masks } from '~/lib/dateformat'; -import { nDaysBefore, getStartOfDay } from '~/lib/utils/datetime_utility'; -import { s__ } from '~/locale'; +import { + nDaysBefore, + getStartOfDay, + dayAfter, + getDateInPast, + getCurrentUtcDate, + nWeeksBefore, +} from '~/lib/utils/datetime_utility'; +import { s__, __, sprintf, n__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; export const DATE_RANGE_LIMIT = 180; +export const DEFAULT_DATE_RANGE = 29; // 30 including current date export const PROJECTS_PER_PAGE = 50; const { isoDate, mediumDate } = masks; @@ -14,10 +22,63 @@ export const dateFormats = { month: 'mmmm', }; +const TODAY = getCurrentUtcDate(); +const TOMORROW = dayAfter(TODAY, { utc: true }); +export const LAST_30_DAYS = getDateInPast(TOMORROW, 30, { utc: true }); + const startOfToday = getStartOfDay(new Date(), { utc: true }); -const last180Days = nDaysBefore(startOfToday, DATE_RANGE_LIMIT, { utc: true }); +const lastXDays = __('Last %{days} days'); +const lastWeek = nWeeksBefore(TOMORROW, 1, { utc: true }); +const last90Days = getDateInPast(TOMORROW, 90, { utc: true }); +const last180Days = getDateInPast(TOMORROW, DATE_RANGE_LIMIT, { utc: true }); +const mrThroughputStartDate = nDaysBefore(startOfToday, DATE_RANGE_LIMIT, { utc: true }); const formatDateParam = (d) => dateFormat(d, dateFormats.isoDate, true); +export const DATE_RANGE_CUSTOM_VALUE = 'custom'; +export const DATE_RANGE_LAST_30_DAYS_VALUE = 'last_30_days'; + +export const DEFAULT_DATE_RANGE_OPTIONS = [ + { + text: __('Last week'), + value: 'last_week', + startDate: lastWeek, + endDate: TODAY, + }, + { + text: sprintf(lastXDays, { days: 30 }), + value: DATE_RANGE_LAST_30_DAYS_VALUE, + startDate: LAST_30_DAYS, + endDate: TODAY, + }, + { + text: sprintf(lastXDays, { days: 90 }), + value: 'last_90_days', + startDate: last90Days, + endDate: TODAY, + }, + { + text: sprintf(lastXDays, { days: 180 }), + value: 'last_180_days', + startDate: last180Days, + endDate: TODAY, + }, +]; + +export const MAX_DATE_RANGE_TEXT = (maxDateRange) => { + return sprintf( + __( + 'Showing data for workflow items completed in this date range. Date range limited to %{maxDateRange} days.', + ), + { + maxDateRange, + }, + ); +}; + +export const NUMBER_OF_DAYS_SELECTED = (numDays) => { + return n__('1 day selected', '%d days selected', numDays); +}; + export const METRIC_POPOVER_LABEL = s__('ValueStreamAnalytics|View details'); export const ISSUES_COMPLETED_TYPE = 'issues_completed'; @@ -147,7 +208,7 @@ export const METRIC_TOOLTIPS = { description: s__('ValueStreamAnalytics|The number of merge requests merged by month.'), groupLink: '-/analytics/productivity_analytics', projectLink: `-/analytics/merge_request_analytics?start_date=${formatDateParam( - last180Days, + mrThroughputStartDate, )}&end_date=${formatDateParam(startOfToday)}`, docsLink: helpPagePath('user/analytics/merge_request_analytics', { anchor: 'view-the-number-of-merge-requests-in-a-date-range', diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 6dfc1c609de..185cdaa1c99 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -33,7 +33,6 @@ const Api = { forkedProjectsPath: '/api/:version/projects/:id/forks', projectLabelsPath: '/:namespace_path/:project_path/-/labels', projectFileSchemaPath: '/:namespace_path/:project_path/-/schema/:ref/:filename', - projectGroupsPath: '/api/:version/projects/:id/groups.json', projectUsersPath: '/api/:version/projects/:id/users', projectInvitationsPath: '/api/:version/projects/:id/invitations', projectMembersPath: '/api/:version/projects/:id/members', @@ -178,19 +177,6 @@ const Api = { }); }, - projectGroups(id, options) { - const url = Api.buildUrl(this.projectGroupsPath).replace(':id', encodeURIComponent(id)); - - return axios - .get(url, { - params: { - ...options, - }, - }) - .then(({ data }) => { - return data; - }); - }, /** * @deprecated This method will be removed soon. Use the * `getGroups` method in `~/rest_api` instead. diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue index 31531c90b94..1cd5854740e 100644 --- a/app/assets/javascripts/badges/components/badge.vue +++ b/app/assets/javascripts/badges/components/badge.vue @@ -80,7 +80,7 @@ export default { :href="linkUrl" target="_blank" rel="noopener noreferrer" - data-qa-selector="badge_image_link" + data-testid="badge-image-link" :data-qa-link-url="linkUrl" > <img diff --git a/app/assets/javascripts/badges/components/badge_list.vue b/app/assets/javascripts/badges/components/badge_list.vue index b69890572eb..12c9662b30d 100644 --- a/app/assets/javascripts/badges/components/badge_list.vue +++ b/app/assets/javascripts/badges/components/badge_list.vue @@ -100,7 +100,7 @@ export default { <template> <div> <gl-loading-icon v-show="isLoading" size="md" /> - <div data-qa-selector="badge_list_content"> + <div data-testid="badge-list-content"> <gl-table :empty-text="emptyMessage" :fields="fields" @@ -109,7 +109,7 @@ export default { :current-page="currentPage" stacked="md" show-empty - data-qa-selector="badge_list" + data-testid="badge-list" > <template #cell(name)="{ item }"> <label class="label-bold str-truncated mb-0">{{ item.name }}</label> diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue index 42fc85cc5fb..2745ccb4682 100644 --- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue @@ -31,14 +31,14 @@ export default { }, }, methods: { - ...mapActions('diffs', ['setCurrentFileHash']), + ...mapActions('diffs', ['goToFile']), ...mapActions('batchComments', ['scrollToDraft']), isOnLatestDiff(draft) { return draft.position?.head_sha === this.getNoteableData.diff_head_sha; }, async onClickDraft(draft) { - if (this.viewDiffsFileByFile && draft.file_hash) { - await this.setCurrentFileHash(draft.file_hash); + if (this.viewDiffsFileByFile) { + await this.goToFile({ path: draft.file_path }); } if (draft.position && !this.isOnLatestDiff(draft)) { @@ -54,7 +54,7 @@ export default { </script> <template> - <gl-disclosure-dropdown :items="listItems" dropup data-qa-selector="review_preview_dropdown"> + <gl-disclosure-dropdown :items="listItems" dropup data-testid="review-preview-dropdown"> <template #toggle> <gl-button> {{ __('Pending comments') }} diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue index 00bb9250403..365b7930dd3 100644 --- a/app/assets/javascripts/batch_comments/components/review_bar.vue +++ b/app/assets/javascripts/batch_comments/components/review_bar.vue @@ -40,7 +40,7 @@ export default { <nav class="review-bar-component js-review-bar" data-testid="review_bar_component"> <div class="review-bar-content d-flex gl-justify-content-end" - data-qa-selector="review_bar_content" + data-testid="review-bar-content" > <preview-dropdown /> <submit-dropdown /> diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue index 72116b1eb7f..fac45f32464 100644 --- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue @@ -6,7 +6,6 @@ import { __ } from '~/locale'; import { createAlert } from '~/alert'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import { scrollToElement } from '~/lib/utils/common_utils'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { CLEAR_AUTOSAVE_ENTRY_EVENT } from '~/vue_shared/constants'; import markdownEditorEventHub from '~/vue_shared/components/markdown/eventhub'; import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking'; @@ -23,7 +22,6 @@ export default { SummarizeMyReview: () => import('ee_component/batch_comments/components/summarize_my_review.vue'), }, - mixins: [glFeatureFlagsMixin()], inject: { canSummarize: { default: false }, }, @@ -127,7 +125,7 @@ export default { dropup class="submit-review-dropdown" :class="{ 'submit-review-dropdown-animated': shouldAnimateReviewButton }" - data-qa-selector="submit_review_dropdown" + data-testid="submit-review-dropdown" variant="info" category="primary" > @@ -151,7 +149,6 @@ export default { <markdown-editor ref="markdownEditor" v-model="noteData.note" - :enable-content-editor="Boolean(glFeatures.contentEditorOnIssues)" class="js-no-autosize" :is-submitting="isSubmitting" :render-markdown-path="getNoteableData.preview_note_path" @@ -192,7 +189,6 @@ export default { type="submit" class="js-no-auto-disable" data-testid="submit-review-button" - data-qa-selector="submit_review_button" > {{ __('Submit review') }} </gl-button> diff --git a/app/assets/javascripts/behaviors/autosize.js b/app/assets/javascripts/behaviors/autosize.js index 181d841a068..6787efbeafa 100644 --- a/app/assets/javascripts/behaviors/autosize.js +++ b/app/assets/javascripts/behaviors/autosize.js @@ -1,11 +1,6 @@ import Autosize from 'autosize'; -import { waitForCSSLoaded } from '~/helpers/startup_css_helper'; -waitForCSSLoaded(() => { - const autosizeEls = document.querySelectorAll('.js-autosize'); +const autosizeEls = document.querySelectorAll('.js-autosize'); - Autosize(autosizeEls); - Autosize.update(autosizeEls); - - autosizeEls.forEach((el) => el.classList.add('js-autosize-initialized')); -}); +Autosize(autosizeEls); +Autosize.update(autosizeEls); diff --git a/app/assets/javascripts/behaviors/components/global_alerts.vue b/app/assets/javascripts/behaviors/components/global_alerts.vue new file mode 100644 index 00000000000..d7333619110 --- /dev/null +++ b/app/assets/javascripts/behaviors/components/global_alerts.vue @@ -0,0 +1,50 @@ +<script> +import { GlAlert } from '@gitlab/ui'; + +import { getGlobalAlerts, setGlobalAlerts, removeGlobalAlertById } from '~/lib/utils/global_alerts'; + +export default { + name: 'GlobalAlerts', + components: { GlAlert }, + data() { + return { + alerts: [], + }; + }, + mounted() { + const { page } = document.body.dataset; + const alerts = getGlobalAlerts(); + + const alertsToPersist = alerts.filter((alert) => alert.persistOnPages.length); + const alertsToRender = alerts.filter( + (alert) => alert.persistOnPages.length === 0 || alert.persistOnPages.includes(page), + ); + + this.alerts = alertsToRender; + + // Once we render the global alerts, we re-set the global alerts to only store persistent alerts for the next load. + setGlobalAlerts(alertsToPersist); + }, + methods: { + onDismiss(index) { + const alert = this.alerts[index]; + this.alerts.splice(index, 1); + removeGlobalAlertById(alert.id); + }, + }, +}; +</script> + +<template> + <div v-if="alerts.length"> + <gl-alert + v-for="(alert, index) in alerts" + :key="alert.id" + :variant="alert.variant" + :title="alert.title" + :dismissible="alert.dismissible" + @dismiss="onDismiss(index)" + >{{ alert.message }}</gl-alert + > + </div> +</template> diff --git a/app/assets/javascripts/behaviors/global_alerts.js b/app/assets/javascripts/behaviors/global_alerts.js new file mode 100644 index 00000000000..476291e6b47 --- /dev/null +++ b/app/assets/javascripts/behaviors/global_alerts.js @@ -0,0 +1,17 @@ +import Vue from 'vue'; + +import GlobalAlerts from './components/global_alerts.vue'; + +export const initGlobalAlerts = () => { + const el = document.getElementById('js-global-alerts'); + + if (!el) return false; + + return new Vue({ + el, + name: 'GlobalAlertsRoot', + render(createElement) { + return createElement(GlobalAlerts); + }, + }); +}; diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js index 871b1279ce6..dc9153e61f7 100644 --- a/app/assets/javascripts/behaviors/index.js +++ b/app/assets/javascripts/behaviors/index.js @@ -9,6 +9,7 @@ import './quick_submit'; import './requires_input'; import initPageShortcuts from './shortcuts'; import { initToastMessages } from './toasts'; +import { initGlobalAlerts } from './global_alerts'; import './toggler_behavior'; import './preview_markdown'; @@ -24,6 +25,8 @@ initCollapseSidebarOnWindowResize(); initToastMessages(); +initGlobalAlerts(); + window.requestIdleCallback( () => { // Check if we have to Load GFM Input diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js index 333858f717c..58b08772337 100644 --- a/app/assets/javascripts/behaviors/markdown/render_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -3,7 +3,6 @@ import highlightCurrentUser from './highlight_current_user'; import { renderKroki } from './render_kroki'; import renderMath from './render_math'; import renderSandboxedMermaid from './render_sandboxed_mermaid'; -import renderObservability from './render_observability'; import { renderJSONTable } from './render_json_table'; function initPopovers(elements) { @@ -21,16 +20,7 @@ export function renderGFM(element) { return; } - const [ - highlightEls, - krokiEls, - mathEls, - mermaidEls, - tableEls, - userEls, - popoverEls, - observabilityEls, - ] = [ + const [highlightEls, krokiEls, mathEls, mermaidEls, tableEls, userEls, popoverEls] = [ '.js-syntax-highlight', '.js-render-kroki[hidden]', '.js-render-math', @@ -38,7 +28,6 @@ export function renderGFM(element) { '[lang="json"][data-lang-params="table"]', '.gfm-project_member', '.gfm-issue, .gfm-work_item, .gfm-merge_request, .gfm-epic', - '.js-render-observability', ].map((selector) => Array.from(element.querySelectorAll(selector))); syntaxHighlight(highlightEls); @@ -47,6 +36,5 @@ export function renderGFM(element) { renderSandboxedMermaid(mermaidEls); renderJSONTable(tableEls.map((e) => e.parentNode)); highlightCurrentUser(userEls); - 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 7525fc76d16..4cba3eccb45 100644 --- a/app/assets/javascripts/behaviors/markdown/render_math.js +++ b/app/assets/javascripts/behaviors/markdown/render_math.js @@ -22,6 +22,31 @@ const waitForReflow = (fn) => { window.requestIdleCallback(fn); }; +const katexOptions = (el) => { + const options = { + displayMode: el.dataset.mathStyle === 'display', + throwOnError: true, + trust: (context) => + // this config option restores the KaTeX pre-v0.11.0 + // behavior of allowing certain commands and protocols + // eslint-disable-next-line @gitlab/require-i18n-strings + ['\\url', '\\href'].includes(context.command) && + ['http', 'https', 'mailto', '_relative'].includes(context.protocol), + }; + + if (gon.math_rendering_limits_enabled) { + options.maxSize = MAX_USER_SPECIFIED_EMS; + // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/111107 for + // reasoning behind this value + options.maxExpand = MAX_MACRO_EXPANSIONS; + } else { + // eslint-disable-next-line @gitlab/require-i18n-strings + options.maxExpand = 'Infinity'; + } + + return options; +}; + /** * Renders math blocks sequentially while protecting against DoS attacks. Math blocks have a maximum character limit of MAX_MATH_CHARS. If rendering math takes longer than MAX_RENDER_TIME_MS, all subsequent math blocks are skipped and an error message is shown. */ @@ -60,7 +85,10 @@ class SafeMathRenderer { } const el = chosenEl || this.queue.shift(); - const forceRender = Boolean(chosenEl) || unrestrictedPages.includes(this.pageName); + const forceRender = + Boolean(chosenEl) || + unrestrictedPages.includes(this.pageName) || + !gon.math_rendering_limits_enabled; const text = el.textContent; el.removeAttribute('style'); @@ -128,20 +156,7 @@ class SafeMathRenderer { } // eslint-disable-next-line no-unsanitized/property - displayContainer.innerHTML = this.katex.renderToString(text, { - displayMode: el.dataset.mathStyle === 'display', - throwOnError: true, - maxSize: MAX_USER_SPECIFIED_EMS, - // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/111107 for - // reasoning behind this value - maxExpand: MAX_MACRO_EXPANSIONS, - trust: (context) => - // this config option restores the KaTeX pre-v0.11.0 - // behavior of allowing certain commands and protocols - // eslint-disable-next-line @gitlab/require-i18n-strings - ['\\url', '\\href'].includes(context.command) && - ['http', 'https', 'mailto', '_relative'].includes(context.protocol), - }); + displayContainer.innerHTML = this.katex.renderToString(text, katexOptions(el)); } catch (e) { // Don't show a flash for now because it would override an existing flash message if (e.message.match(/Too many expansions/)) { diff --git a/app/assets/javascripts/behaviors/markdown/render_observability.js b/app/assets/javascripts/behaviors/markdown/render_observability.js deleted file mode 100644 index 6346fb8ab48..00000000000 --- a/app/assets/javascripts/behaviors/markdown/render_observability.js +++ /dev/null @@ -1,25 +0,0 @@ -import Vue from 'vue'; -import ObservabilityApp from '~/observability/components/observability_app.vue'; -import { SKELETON_VARIANT_EMBED, INLINE_EMBED_DIMENSIONS } from '~/observability/constants'; - -const mountVueComponent = (element) => { - const url = element.dataset.frameUrl; - return new Vue({ - el: element, - render(h) { - return h(ObservabilityApp, { - props: { - observabilityIframeSrc: url, - inlineEmbed: true, - skeletonVariant: SKELETON_VARIANT_EMBED, - height: INLINE_EMBED_DIMENSIONS.HEIGHT, - width: INLINE_EMBED_DIMENSIONS.WIDTH, - }, - }); - }, - }); -}; - -export default function renderObservability(elements) { - return elements.map(mountVueComponent); -} diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js index ce77ede9fe4..6e0b1250479 100644 --- a/app/assets/javascripts/behaviors/preview_markdown.js +++ b/app/assets/javascripts/behaviors/preview_markdown.js @@ -131,10 +131,13 @@ $(document).on('markdown-preview:show', (e, $form) => { lastTextareaPreviewed = $form.find('textarea.markdown-area'); lastTextareaHeight = lastTextareaPreviewed.height(); - // toggle tabs - $form.find(previewButtonSelector).val('edit'); - $form.find(previewButtonSelector).children('span.gl-button-text').text(__('Continue editing')); - $form.find(previewButtonSelector).addClass('gl-shadow-none! gl-bg-transparent!'); + const $previewButton = $form.find(previewButtonSelector); + + if (!$previewButton.parents('.js-vue-markdown-field').length) { + $previewButton.val('edit'); + $previewButton.children('span.gl-button-text').text(__('Continue editing')); + $previewButton.addClass('gl-shadow-none! gl-bg-transparent!'); + } // toggle content $form.find('.md-write-holder').hide(); @@ -154,9 +157,12 @@ $(document).on('markdown-preview:hide', (e, $form) => { $form.find('textarea.markdown-area').height(lastTextareaHeight); } - // toggle tabs - $form.find(previewButtonSelector).val('preview'); - $form.find(previewButtonSelector).children('span.gl-button-text').text(__('Preview')); + const $previewButton = $form.find(previewButtonSelector); + + if (!$previewButton.parents('.js-vue-markdown-field').length) { + $previewButton.val('preview'); + $previewButton.children('span.gl-button-text').text(__('Preview')); + } // toggle content $form.find('.md-write-holder').show(); diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue index cb9997b7c54..5592a75a4d2 100644 --- a/app/assets/javascripts/blob/components/blob_content.vue +++ b/app/assets/javascripts/blob/components/blob_content.vue @@ -98,7 +98,7 @@ export default { :file-name="blob.name" :type="activeViewer.fileType" :hide-line-numbers="hideLineNumbers" - data-qa-selector="blob_viewer_file_content" + data-testid="blob-viewer-file-content" @richContentLoaded="richContentLoaded = true" /> </template> diff --git a/app/assets/javascripts/blob/components/blob_edit_header.vue b/app/assets/javascripts/blob/components/blob_edit_header.vue index 8fd3f03ff71..cd2872026c1 100644 --- a/app/assets/javascripts/blob/components/blob_edit_header.vue +++ b/app/assets/javascripts/blob/components/blob_edit_header.vue @@ -48,7 +48,7 @@ export default { variant="danger" category="secondary" :disabled="!canDelete" - data-qa-selector="delete_file_button" + data-testid="delete-file-button" @click="$emit('delete')" >{{ s__('Snippets|Delete file') }}</gl-button > diff --git a/app/assets/javascripts/blob/components/blob_header_default_actions.vue b/app/assets/javascripts/blob/components/blob_header_default_actions.vue index 12a198f78ea..ddc135e2de7 100644 --- a/app/assets/javascripts/blob/components/blob_header_default_actions.vue +++ b/app/assets/javascripts/blob/components/blob_header_default_actions.vue @@ -96,7 +96,7 @@ export default { }; </script> <template> - <gl-button-group data-qa-selector="default_actions_container"> + <gl-button-group data-testid="default-actions-container"> <gl-button v-if="showCopyButton" v-gl-tooltip.hover @@ -104,8 +104,7 @@ export default { :title="$options.BTN_COPY_CONTENTS_TITLE" :disabled="copyDisabled" :data-clipboard-target="getBlobHashTarget" - data-testid="copyContentsButton" - data-qa-selector="copy_contents_button" + data-testid="copy-contents-button" icon="copy-to-clipboard" category="primary" variant="default" diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue index 95b88937c32..9187b45788a 100644 --- a/app/assets/javascripts/blob/components/blob_header_filepath.vue +++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue @@ -49,7 +49,7 @@ export default { <file-icon :file-name="fileName" :size="16" aria-hidden="true" css-classes="gl-mr-3" /> <strong class="file-title-name mr-1 js-blob-header-filepath" - data-qa-selector="file_title_content" + data-testid="file-title-content" >{{ fileName }}</strong > </template> diff --git a/app/assets/javascripts/blob/csv/constants.js b/app/assets/javascripts/blob/csv/constants.js new file mode 100644 index 00000000000..7445b653d28 --- /dev/null +++ b/app/assets/javascripts/blob/csv/constants.js @@ -0,0 +1 @@ +export const MAX_ROWS_TO_RENDER = 2000; diff --git a/app/assets/javascripts/blob/csv/csv_viewer.vue b/app/assets/javascripts/blob/csv/csv_viewer.vue index 169167625e0..7231d023024 100644 --- a/app/assets/javascripts/blob/csv/csv_viewer.vue +++ b/app/assets/javascripts/blob/csv/csv_viewer.vue @@ -1,12 +1,15 @@ <script> -import { GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { GlLoadingIcon, GlTable, GlButton } from '@gitlab/ui'; import Papa from 'papaparse'; +import { setUrlParams } from '~/lib/utils/url_utility'; import PapaParseAlert from '~/vue_shared/components/papa_parse_alert.vue'; +import { MAX_ROWS_TO_RENDER } from './constants'; export default { components: { PapaParseAlert, GlTable, + GlButton, GlLoadingIcon, }, props: { @@ -25,8 +28,14 @@ export default { items: [], papaParseErrors: [], loading: true, + isTooLarge: false, }; }, + computed: { + pathToRawFile() { + return setUrlParams({ plain: 1 }); + }, + }, mounted() { if (!this.remoteFile) { const parsed = Papa.parse(this.csv, { skipEmptyLines: true }); @@ -43,7 +52,11 @@ export default { }, methods: { handleParsedData(parsed) { - this.items = parsed.data; + if (parsed.data.length > MAX_ROWS_TO_RENDER) { + this.isTooLarge = true; + } + + this.items = parsed.data.slice(0, MAX_ROWS_TO_RENDER); if (parsed.errors.length) { this.papaParseErrors = parsed.errors; @@ -63,12 +76,28 @@ export default { <div v-else> <papa-parse-alert v-if="papaParseErrors.length" :papa-parse-errors="papaParseErrors" /> <gl-table - :empty-text="__('No CSV data to display.')" + :empty-text="s__('CsvViewer|No CSV data to display.')" :items="items" :fields="$options.fields" show-empty thead-class="gl-display-none" /> + <div + v-if="isTooLarge" + class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-p-5" + > + <p data-testid="large-csv-text"> + {{ + s__( + 'CsvViewer|The file is too large to render all the rows. To see the entire file, switch to the raw view.', + ) + }} + </p> + + <gl-button category="secondary" variant="confirm" :href="pathToRawFile">{{ + s__('CsvViewer|View raw data') + }}</gl-button> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue index 1cfa35ffd91..4d915ff341a 100644 --- a/app/assets/javascripts/boards/components/board_app.vue +++ b/app/assets/javascripts/boards/components/board_app.vue @@ -1,6 +1,7 @@ <script> // eslint-disable-next-line no-restricted-imports import { mapGetters } from 'vuex'; +import { omit } from 'lodash'; import { refreshCurrentPage, queryToObject } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import BoardContent from '~/boards/components/board_content.vue'; @@ -115,9 +116,8 @@ export default { return this.activeListId ? this.boardListsApollo[this.activeListId] : undefined; }, formattedFilterParams() { - if (this.filterParams.groupBy) delete this.filterParams.groupBy; return filterVariables({ - filters: this.filterParams, + filters: omit(this.filterParams, 'groupBy'), issuableType: this.issuableType, filterInfo: FiltersInfo, filterFields: FilterFields, diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index 05865dc7305..fd45d2d31c3 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -2,6 +2,9 @@ // eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import Tracking from '~/tracking'; +import setSelectedBoardItemsMutation from '~/boards/graphql/client/set_selected_board_items.mutation.graphql'; +import unsetSelectedBoardItemsMutation from '~/boards/graphql/client/unset_selected_board_items.mutation.graphql'; +import selectedBoardItemsQuery from '~/boards/graphql/client/selected_board_items.query.graphql'; import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql'; import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; import BoardCardInner from './board_card_inner.vue'; @@ -52,9 +55,12 @@ export default { return !this.isApolloBoard; }, }, + selectedBoardItems: { + query: selectedBoardItemsQuery, + }, }, computed: { - ...mapState(['selectedBoardItems', 'activeId']), + ...mapState(['activeId']), activeItemId() { return this.isApolloBoard ? this.activeBoardItem?.id : this.activeId; }, @@ -62,10 +68,7 @@ export default { return this.item.id === this.activeItemId; }, multiSelectVisible() { - return ( - !this.activeItemId && - this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.item.id) > -1 - ); + return !this.activeItemId && this.selectedBoardItems?.includes(this.item.id); }, isDisabled() { return this.disabled || !this.item.id || this.item.isLoading || !this.canAdmin; @@ -93,7 +96,7 @@ export default { }, }, methods: { - ...mapActions(['toggleBoardItemMultiSelection', 'toggleBoardItem']), + ...mapActions(['toggleBoardItem']), toggleIssue(e) { // Don't do anything if this happened on a no trigger element if (e.target.closest('.js-no-trigger')) return; @@ -110,7 +113,10 @@ export default { this.track('click_card', { label: 'right_sidebar' }); } }, - toggleItem() { + async toggleItem() { + await this.$apollo.mutate({ + mutation: unsetSelectedBoardItemsMutation, + }); this.$apollo.mutate({ mutation: setActiveBoardItemMutation, variables: { @@ -119,13 +125,32 @@ export default { }, }); }, + async toggleBoardItemMultiSelection(item) { + if (this.activeItemId) { + await this.$apollo.mutate({ + mutation: setSelectedBoardItemsMutation, + variables: { + itemId: this.activeItemId, + }, + }); + await this.$apollo.mutate({ + mutation: setActiveBoardItemMutation, + variables: { boardItem: null }, + }); + } + this.$apollo.mutate({ + mutation: setSelectedBoardItemsMutation, + variables: { + itemId: item.id, + }, + }); + }, }, }; </script> <template> <li - data-qa-selector="board_card" :class="[ { 'multi-select gl-bg-blue-50 gl-border-blue-200': multiSelectVisible, @@ -141,7 +166,7 @@ export default { :data-item-iid="item.iid" :data-item-path="item.referencePath" :style="cardStyle" - data-testid="board_card" + data-testid="board-card" class="board-card gl-p-5 gl-rounded-base gl-line-height-normal gl-relative gl-mb-3" @click="toggleIssue($event)" > diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index c441a718dd8..c10ff2e08da 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -9,11 +9,12 @@ import { } from '@gitlab/ui'; import { sortBy } from 'lodash'; // eslint-disable-next-line no-restricted-imports -import { mapActions, mapState } from 'vuex'; +import { mapActions } from 'vuex'; import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { updateHistory } from '~/lib/utils/url_utility'; import { sprintf, __, n__ } from '~/locale'; +import isShowingLabelsQuery from '~/graphql_shared/client/is_showing_labels.query.graphql'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; @@ -86,8 +87,13 @@ export default { maxCounter: 99, }; }, + apollo: { + isShowingLabels: { + query: isShowingLabelsQuery, + update: (data) => data.isShowingLabels, + }, + }, computed: { - ...mapState(['isShowingLabels']), isLoading() { return this.item.isLoading || this.item.iid === '-1'; }, @@ -252,7 +258,7 @@ export default { v-if="item.hidden" v-gl-tooltip name="spam" - :title="__('This issue is hidden because its author has been banned')" + :title="__('This issue is hidden because its author has been banned.')" class="gl-mr-2 hidden-icon gl-text-orange-500 gl-cursor-help" data-testid="hidden-icon" /> diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index bcd7db8dcb4..67a4c5eba45 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -93,7 +93,7 @@ export default { }" :data-list-id="list.id" class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal is-expandable" - data-qa-selector="board_list" + data-testid="board-list" > <div class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-gray-50" diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 3c2659b00c9..554f3bfa416 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -219,7 +219,7 @@ export default { <template> <div v-cloak - data-qa-selector="boards_list" + data-testid="boards-list" class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-min-h-0" > <gl-alert v-if="errorToDisplay" variant="danger" :dismissible="true" @dismiss="dismissError"> diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index d12478b42d8..a3d55ac8306 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -1,13 +1,15 @@ <script> import { GlModal, GlAlert } from '@gitlab/ui'; // eslint-disable-next-line no-restricted-imports -import { mapActions, mapState } from 'vuex'; +import { mapActions } from 'vuex'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { visitUrl, updateHistory, getParameterByName } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; import eventHub from '~/boards/eventhub'; import { formType } from '../constants'; +import { setError } from '../graphql/cache_updates'; +import errorQuery from '../graphql/client/error.query.graphql'; import createBoardMutation from '../graphql/board_create.mutation.graphql'; import destroyBoardMutation from '../graphql/board_destroy.mutation.graphql'; import updateBoardMutation from '../graphql/board_update.mutation.graphql'; @@ -93,8 +95,13 @@ export default { isLoading: false, }; }, + apollo: { + error: { + query: errorQuery, + update: (data) => data.boardsAppError, + }, + }, computed: { - ...mapState(['error']), isNewForm() { return this.currentPage === formType.new; }, @@ -133,7 +140,7 @@ export default { variant: this.buttonKind, disabled: this.submitDisabled, loading: this.isLoading, - 'data-qa-selector': 'save_changes_button', + 'data-testid': 'save-changes-button', }, }; }, @@ -177,7 +184,8 @@ export default { } }, methods: { - ...mapActions(['setError', 'unsetError', 'setBoard']), + ...mapActions(['setBoard']), + setError, isFocusMode() { return Boolean(document.querySelector('.content-wrapper > .js-focus-mode-board.is-focused')); }, @@ -211,8 +219,8 @@ export default { try { await this.deleteBoard(); visitUrl(this.boardBaseUrl); - } catch { - this.setError({ message: this.$options.i18n.deleteErrorMessage }); + } catch (error) { + setError({ error, message: this.$options.i18n.deleteErrorMessage }); } finally { this.isLoading = false; } @@ -236,8 +244,8 @@ export default { : ''; updateHistory({ url: `${this.boardBaseUrl}/${getIdFromGraphQLId(board.id)}${param}` }); } - } catch { - this.setError({ message: this.$options.i18n.saveErrorMessage }); + } catch (error) { + setError({ error, message: this.$options.i18n.saveErrorMessage }); } finally { this.isLoading = false; } @@ -295,11 +303,11 @@ export default { @hide.prevent > <gl-alert - v-if="!isApolloBoard && error" + v-if="error" class="gl-mb-3" variant="danger" :dismissible="true" - @dismiss="unsetError" + @dismiss="() => setError({ message: null, captureError: false })" > {{ error }} </gl-alert> @@ -316,7 +324,7 @@ export default { ref="name" v-model="board.name" class="form-control" - data-qa-selector="board_name_field" + data-testid="board-name-field" type="text" :placeholder="$options.i18n.titleFieldPlaceholder" @keyup.enter="submit" diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 1bb7e88122a..2693a6bb5ea 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -653,7 +653,7 @@ export default { <div v-show="!list.collapsed" class="board-list-component gl-relative gl-h-full gl-display-flex gl-flex-direction-column gl-min-h-0" - data-qa-selector="board_list_cards_area" + data-testid="board-list-cards-area" > <div v-if="loading" diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 42c30dc8245..0235edd69ac 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -201,8 +201,8 @@ export default { }) ); }, - totalWeight() { - return this.boardList?.totalWeight; + totalIssueWeight() { + return this.boardList?.totalIssueWeight; }, canShowTotalWeight() { return this.weightFeatureAvailable && !this.isLoading; @@ -365,7 +365,6 @@ export default { }" :style="headerStyle" class="board-header gl-relative" - data-qa-selector="board_list_header" data-testid="board-list-header" > <h3 @@ -473,8 +472,8 @@ export default { <div v-else>• {{ itemsTooltipLabel }}</div> <div v-if="weightFeatureAvailable && !isLoading"> • - <gl-sprintf :message="__('%{totalWeight} total weight')"> - <template #totalWeight>{{ totalWeight }}</template> + <gl-sprintf :message="__('%{totalIssueWeight} total weight')"> + <template #totalIssueWeight>{{ totalIssueWeight }}</template> </gl-sprintf> </div> </gl-tooltip> @@ -507,7 +506,7 @@ export default { <gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" /> <span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3" data-testid="weight"> <gl-icon class="gl-mr-2" name="weight" :size="14" /> - {{ totalWeight }} + {{ totalIssueWeight }} </span> </template> <!-- EE end --> diff --git a/app/assets/javascripts/boards/components/board_top_bar.vue b/app/assets/javascripts/boards/components/board_top_bar.vue index 7fd1a934381..31664c28831 100644 --- a/app/assets/javascripts/boards/components/board_top_bar.vue +++ b/app/assets/javascripts/boards/components/board_top_bar.vue @@ -4,6 +4,7 @@ import { s__ } from '~/locale'; import BoardsSelector from 'ee_else_ce/boards/components/boards_selector.vue'; import IssueBoardFilteredSearch from 'ee_else_ce/boards/components/issue_board_filtered_search.vue'; import { getBoardQuery } from 'ee_else_ce/boards/boards_util'; +import ToggleLabels from '~/vue_shared/components/toggle_labels.vue'; import { setError } from '../graphql/cache_updates'; import ConfigToggle from './config_toggle.vue'; import NewBoardButton from './new_board_button.vue'; @@ -17,7 +18,7 @@ export default { ConfigToggle, NewBoardButton, ToggleFocus, - ToggleLabels: () => import('ee_component/boards/components/toggle_labels.vue'), + ToggleLabels, ToggleEpicsSwimlanes: () => import('ee_component/boards/components/toggle_epics_swimlanes.vue'), EpicBoardFilteredSearch: () => import('ee_component/boards/components/epic_filtered_search.vue'), diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index cc6fde92f9b..cd2a4a02b2e 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -1,15 +1,7 @@ <script> -import { - GlLoadingIcon, - GlSearchBoxByType, - GlDropdown, - GlDropdownDivider, - GlDropdownSectionHeader, - GlDropdownItem, - GlModalDirective, -} from '@gitlab/ui'; +import { GlButton, GlCollapsibleListbox, GlModalDirective } from '@gitlab/ui'; import { produce } from 'immer'; -import { throttle } from 'lodash'; +import { differenceBy, debounce } from 'lodash'; // eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; @@ -18,7 +10,8 @@ import BoardForm from 'ee_else_ce/boards/components/board_form.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { isMetaKey } from '~/lib/utils/common_utils'; import { updateHistory } from '~/lib/utils/url_utility'; -import { s__ } from '~/locale'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { s__, __ } from '~/locale'; import eventHub from '../eventhub'; import groupBoardsQuery from '../graphql/group_boards.query.graphql'; @@ -34,15 +27,16 @@ export default { name: 'BoardsSelector', i18n: { fetchBoardsError: s__('Boards|An error occurred while fetching boards. Please try again.'), + headerText: s__('IssueBoards|Switch board'), + noResultsText: s__('IssueBoards|No matching boards found'), + hiddenBoardsText: s__( + 'IssueBoards|Some of your boards are hidden, add a license to see them again.', + ), }, components: { BoardForm, - GlLoadingIcon, - GlSearchBoxByType, - GlDropdown, - GlDropdownDivider, - GlDropdownSectionHeader, - GlDropdownItem, + GlButton, + GlCollapsibleListbox, }, directives: { GlModalDirective, @@ -60,11 +54,6 @@ export default { 'isApolloBoard', ], props: { - throttleDuration: { - type: Number, - default: 200, - required: false, - }, boardApollo: { type: Object, required: false, @@ -78,13 +67,10 @@ export default { }, data() { return { - hasScrollFade: false, - scrollFadeInitialized: false, boards: [], recentBoards: [], loadingBoards: false, loadingRecentBoards: false, - throttledSetScrollFade: throttle(this.setScrollFade, this.throttleDuration), contentClientHeight: 0, maxPosition: 0, filterTerm: '', @@ -97,6 +83,12 @@ export default { boardToUse() { return this.isApolloBoard ? this.boardApollo : this.board; }, + boardToUseName() { + return this.boardToUse?.name || s__('IssueBoards|Select board'); + }, + boardToUseId() { + return getIdFromGraphQLId(this.boardToUse.id) || ''; + }, isBoardToUseLoading() { return this.isApolloBoard ? this.isCurrentBoardLoading : this.isBoardLoading; }, @@ -112,6 +104,26 @@ export default { loading() { return this.loadingRecentBoards || this.loadingBoards; }, + listBoxItems() { + const mapItems = ({ id, name }) => ({ text: name, value: id }); + + if (this.showRecentSection) { + const notRecent = differenceBy(this.filteredBoards, this.recentBoards, 'id'); + + return [ + { + text: __('Recent'), + options: this.recentBoards.map(mapItems), + }, + { + text: __('All'), + options: notRecent.map(mapItems), + }, + ]; + } + + return this.filteredBoards.map(mapItems); + }, filteredBoards() { return this.boards.filter((board) => board.name.toLowerCase().includes(this.filterTerm.toLowerCase()), @@ -126,34 +138,25 @@ export default { showDropdown() { return this.showCreate || this.hasMissingBoards; }, - scrollFadeClass() { - return { - 'fade-out': !this.hasScrollFade, - }; - }, showRecentSection() { return ( - this.recentBoards.length && + this.recentBoards.length > 0 && this.boards.length > MIN_BOARDS_TO_VIEW_RECENT && !this.filterTerm.length ); }, }, watch: { - filteredBoards() { - this.scrollFadeInitialized = false; - this.$nextTick(this.setScrollFade); - }, - recentBoards() { - this.scrollFadeInitialized = false; - this.$nextTick(this.setScrollFade); - }, boardToUse(newBoard) { document.title = newBoard.name; }, }, created() { eventHub.$on('showBoardModal', this.showPage); + this.handleSearch = debounce(this.setFilterTerm, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + destroyed() { + this.handleSearch.cancel(); }, beforeDestroy() { eventHub.$off('showBoardModal', this.showPage); @@ -248,34 +251,6 @@ export default { this.$emit('switchBoard', board.id); }, - isScrolledUp() { - const { content } = this.$refs; - - if (!content) { - return false; - } - - const currentPosition = this.contentClientHeight + content.scrollTop; - - return currentPosition < this.maxPosition; - }, - initScrollFade() { - const { content } = this.$refs; - - if (!content) { - return; - } - - this.scrollFadeInitialized = true; - - this.contentClientHeight = content.clientHeight; - this.maxPosition = content.scrollHeight; - }, - setScrollFade() { - if (!this.scrollFadeInitialized) this.initScrollFade(); - - this.hasScrollFade = this.isScrolledUp(); - }, fetchCurrentBoard(boardId) { this.fetchBoard({ fullPath: this.fullPath, @@ -283,17 +258,24 @@ export default { boardType: this.boardType, }); }, - async switchBoard(boardId, e) { + setFilterTerm(value) { + this.filterTerm = value; + }, + async switchBoardKeyEvent(boardId, e) { if (isMetaKey(e)) { + e.stopPropagation(); window.open(`${this.boardBaseUrl}/${boardId}`, '_blank'); - } else if (this.isApolloBoard) { + } + }, + switchBoardGroup(value) { + if (this.isApolloBoard) { // Epic board ID is supported in EE version of this file - this.$emit('switchBoard', this.fullBoardId(boardId)); - updateHistory({ url: `${this.boardBaseUrl}/${boardId}` }); + this.$emit('switchBoard', this.fullBoardId(value)); + updateHistory({ url: `${this.boardBaseUrl}/${value}` }); } else { this.unsetActiveId(); - this.fetchCurrentBoard(boardId); - updateHistory({ url: `${this.boardBaseUrl}/${boardId}` }); + this.fetchCurrentBoard(value); + updateHistory({ url: `${this.boardBaseUrl}/${value}` }); } }, }, @@ -303,105 +285,65 @@ export default { <template> <div class="boards-switcher gl-mr-3" data-testid="boards-selector"> <span class="boards-selector-wrapper"> - <gl-dropdown + <gl-collapsible-listbox v-if="showDropdown" + block data-testid="boards-dropdown" - data-qa-selector="boards_dropdown" - toggle-class="dropdown-menu-toggle" - menu-class="flex-column dropdown-extended-height" + searchable + :searching="loading" + toggle-class="gl-min-w-20" + :header-text="$options.i18n.headerText" + :no-results-text="$options.i18n.noResultsText" :loading="isBoardToUseLoading" - :text="boardToUse.name" - @show="loadBoards" + :items="listBoxItems" + :toggle-text="boardToUseName" + :selected="boardToUseId" + @search="handleSearch" + @select="switchBoardGroup" + @shown="loadBoards" > - <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" /> - - <div - v-if="!loading" - ref="content" - data-qa-selector="boards_dropdown_content" - class="dropdown-content flex-fill" - @scroll.passive="throttledSetScrollFade" - > - <gl-dropdown-item - v-show="filteredBoards.length === 0" - class="gl-pointer-events-none text-secondary" - > - {{ s__('IssueBoards|No matching boards found') }} - </gl-dropdown-item> - - <gl-dropdown-section-header v-if="showRecentSection"> - {{ __('Recent') }} - </gl-dropdown-section-header> - - <template v-if="showRecentSection"> - <gl-dropdown-item - v-for="recentBoard in recentBoards" - :key="`recent-${recentBoard.id}`" - data-testid="dropdown-item" - @click.prevent="switchBoard(recentBoard.id, $event)" - > - {{ recentBoard.name }} - </gl-dropdown-item> - </template> - - <gl-dropdown-divider v-if="showRecentSection" /> - - <gl-dropdown-section-header v-if="showRecentSection"> - {{ __('All') }} - </gl-dropdown-section-header> - - <gl-dropdown-item - v-for="otherBoard in filteredBoards" - :key="otherBoard.id" - data-testid="dropdown-item" - @click.prevent="switchBoard(otherBoard.id, $event)" - > - {{ otherBoard.name }} - </gl-dropdown-item> - - <gl-dropdown-item v-if="hasMissingBoards" class="no-pointer-events"> + <template #list-item="{ item }"> + <div data-testid="dropdown-item-recent" @click="switchBoardKeyEvent(item.value, $event)"> + {{ item.text }} + </div> + </template> + + <template #footer> + <div v-if="hasMissingBoards" class="gl-border-t gl-font-sm gl-px-4 gl-pt-4 gl-pb-3"> {{ s__('IssueBoards|Some of your boards are hidden, add a license to see them again.') }} - </gl-dropdown-item> - </div> - - <div - v-show="filteredBoards.length > 0" - class="dropdown-content-faded-mask" - :class="scrollFadeClass" - ></div> - - <gl-loading-icon v-if="loading" size="sm" /> - - <div v-if="canAdminBoard"> - <gl-dropdown-divider /> - - <gl-dropdown-item - v-if="showCreate" - v-gl-modal-directive="'board-config-modal'" - data-qa-selector="create_new_board_button" - data-track-action="click_button" - data-track-label="create_new_board" - data-track-property="dropdown" - @click.prevent="showPage('new')" - > - {{ s__('IssueBoards|Create new board') }} - </gl-dropdown-item> - - <gl-dropdown-item - v-if="showDelete" - v-gl-modal-directive="'board-config-modal'" - class="text-danger" - @click.prevent="showPage('delete')" - > - {{ s__('IssueBoards|Delete board') }} - </gl-dropdown-item> - </div> - </gl-dropdown> + </div> + <div v-if="canAdminBoard" class="gl-border-t gl-py-2 gl-px-2"> + <gl-button + v-if="showCreate" + v-gl-modal-directive="'board-config-modal'" + block + class="gl-justify-content-start!" + category="tertiary" + data-testid="create-new-board-button" + data-track-action="click_button" + data-track-label="create_new_board" + data-track-property="dropdown" + @click="showPage('new')" + > + {{ s__('IssueBoards|Create new board') }} + </gl-button> + + <gl-button + v-if="showDelete" + v-gl-modal-directive="'board-config-modal'" + block + category="tertiary" + variant="danger" + class="gl-mt-0! gl-justify-content-start!" + @click="showPage('delete')" + > + {{ s__('IssueBoards|Delete board') }} + </gl-button> + </div> + </template> + </gl-collapsible-listbox> <board-form v-if="currentPage" diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue index bc896932ffc..69e6cc870d2 100644 --- a/app/assets/javascripts/boards/components/config_toggle.vue +++ b/app/assets/javascripts/boards/components/config_toggle.vue @@ -49,7 +49,7 @@ export default { v-gl-tooltip :title="tooltipTitle" :class="{ 'dot-highlight': hasScope || boardHasScope }" - data-qa-selector="boards_config_button" + data-testid="boards-config-button" @click.prevent="showPage" > {{ buttonText }} 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 a7b3f5536a4..c28415de620 100644 --- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue @@ -62,11 +62,7 @@ export default { tokensCE() { const { issue, incident } = this.$options.i18n; const { types } = this.$options; - const { fetchUsers, fetchLabels } = issueBoardFilters( - this.$apollo, - this.fullPath, - this.isGroupBoard, - ); + const { fetchLabels } = issueBoardFilters(this.$apollo, this.fullPath, this.isGroupBoard); const tokens = [ { @@ -77,7 +73,8 @@ export default { token: UserToken, dataType: 'user', unique: true, - fetchUsers, + isProject: !this.isGroupBoard, + fullPath: this.fullPath, preloadedUsers: this.preloadedUsers(), }, { @@ -89,7 +86,8 @@ export default { token: UserToken, dataType: 'user', unique: true, - fetchUsers, + isProject: !this.isGroupBoard, + fullPath: this.fullPath, preloadedUsers: this.preloadedUsers(), }, { diff --git a/app/assets/javascripts/boards/components/toggle_focus.vue b/app/assets/javascripts/boards/components/toggle_focus.vue index 990a6fa63d4..a886abf9e61 100644 --- a/app/assets/javascripts/boards/components/toggle_focus.vue +++ b/app/assets/javascripts/boards/components/toggle_focus.vue @@ -38,7 +38,7 @@ export default { v-gl-tooltip category="tertiary" :icon="isFullscreen ? 'minimize' : 'maximize'" - data-qa-selector="focus_mode_button" + data-testid="focus-mode-button" :title="$options.i18n.toggleFocusMode" :aria-label="$options.i18n.toggleFocusMode" @click="toggleFocusMode" diff --git a/app/assets/javascripts/boards/graphql/cache_updates.js b/app/assets/javascripts/boards/graphql/cache_updates.js index 3551c3ed982..ea099e02181 100644 --- a/app/assets/javascripts/boards/graphql/cache_updates.js +++ b/app/assets/javascripts/boards/graphql/cache_updates.js @@ -68,7 +68,7 @@ export function updateIssueCountAndWeight({ boardList: { ...boardList, issuesCount: boardList.issuesCount - 1, - totalWeight: boardList.totalWeight - issue.weight, + totalIssueWeight: boardList.totalIssueWeight - issue.weight, }, }), ); @@ -83,7 +83,7 @@ export function updateIssueCountAndWeight({ boardList: { ...boardList, issuesCount: boardList.issuesCount + 1, - totalWeight: boardList.totalWeight + issue.weight, + ...(issue.weight ? { totalIssueWeight: boardList.totalIssueWeight + issue.weight } : {}), }, }), ); diff --git a/app/assets/javascripts/boards/graphql/client/selected_board_items.query.graphql b/app/assets/javascripts/boards/graphql/client/selected_board_items.query.graphql new file mode 100644 index 00000000000..88006750221 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/client/selected_board_items.query.graphql @@ -0,0 +1,3 @@ +query selectedBoardItems { + selectedBoardItems @client +} diff --git a/app/assets/javascripts/boards/graphql/client/set_selected_board_items.mutation.graphql b/app/assets/javascripts/boards/graphql/client/set_selected_board_items.mutation.graphql new file mode 100644 index 00000000000..28274de6c3f --- /dev/null +++ b/app/assets/javascripts/boards/graphql/client/set_selected_board_items.mutation.graphql @@ -0,0 +1,3 @@ +mutation setSelectedBoardItems($itemId: ID!) { + setSelectedBoardItems(itemId: $itemId) @client +} diff --git a/app/assets/javascripts/boards/graphql/client/unset_selected_board_items.mutation.graphql b/app/assets/javascripts/boards/graphql/client/unset_selected_board_items.mutation.graphql new file mode 100644 index 00000000000..ab34cf48609 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/client/unset_selected_board_items.mutation.graphql @@ -0,0 +1,3 @@ +mutation unsetSelectedBoardItems { + unsetSelectedBoardItems @client +} diff --git a/app/assets/javascripts/boards/issue_board_filters.js b/app/assets/javascripts/boards/issue_board_filters.js index ba5da70c6ec..0a6580dd49b 100644 --- a/app/assets/javascripts/boards/issue_board_filters.js +++ b/app/assets/javascripts/boards/issue_board_filters.js @@ -1,5 +1,3 @@ -import { BoardType } from 'ee_else_ce/boards/constants'; -import usersAutocompleteQuery from '~/graphql_shared/queries/users_autocomplete.query.graphql'; import boardLabels from './graphql/board_labels.query.graphql'; export default function issueBoardFilters(apollo, fullPath, isGroupBoard) { @@ -7,17 +5,6 @@ export default function issueBoardFilters(apollo, fullPath, isGroupBoard) { return isGroupBoard ? data.group?.labels.nodes || [] : data.project?.labels.nodes || []; }; - const fetchUsers = (usersSearchTerm) => { - const namespace = isGroupBoard ? BoardType.group : BoardType.project; - - return apollo - .query({ - query: usersAutocompleteQuery, - variables: { fullPath, search: usersSearchTerm, isProject: !isGroupBoard }, - }) - .then(({ data }) => data[namespace]?.autocompleteUsers); - }; - const fetchLabels = (labelSearchTerm) => { return apollo .query({ @@ -34,6 +21,5 @@ export default function issueBoardFilters(apollo, fullPath, isGroupBoard) { return { fetchLabels, - fetchUsers, }; } diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index e044283534a..3e7d7a7a8d3 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -621,7 +621,7 @@ export default { __typename: 'BoardList', id: fromList.boardList.id, issuesCount: fromList.boardList.issuesCount - 1, - totalWeight: fromList.boardList.totalWeight - Number(weight), + totalIssueWeight: fromList.boardList.totalIssueWeight - Number(weight), }, }; @@ -645,7 +645,7 @@ export default { __typename: 'BoardList', id: toList.boardList.id, issuesCount: toList.boardList.issuesCount + 1, - totalWeight: toList.boardList.totalWeight + Number(weight), + totalIssueWeight: toList.boardList.totalIssueWeight + Number(weight), }, }; @@ -731,7 +731,7 @@ export default { __typename: 'BoardList', id: fromList.boardList.id, issuesCount: fromList.boardList.issuesCount + 1, - totalWeight: fromList.boardList.totalWeight, + totalIssueWeight: fromList.boardList.totalIssueWeight, }, }; diff --git a/app/assets/javascripts/branches/components/delete_merged_branches.vue b/app/assets/javascripts/branches/components/delete_merged_branches.vue index 50fe610d335..24ae9b83b9c 100644 --- a/app/assets/javascripts/branches/components/delete_merged_branches.vue +++ b/app/assets/javascripts/branches/components/delete_merged_branches.vue @@ -1,5 +1,12 @@ <script> -import { GlDisclosureDropdown, GlButton, GlFormInput, GlModal, GlSprintf } from '@gitlab/ui'; +import { + GlDisclosureDropdown, + GlButton, + GlFormInput, + GlModal, + GlSprintf, + GlTooltipDirective, +} from '@gitlab/ui'; import csrf from '~/lib/utils/csrf'; import { sprintf, s__, __ } from '~/locale'; @@ -22,6 +29,7 @@ export const i18n = { 'Branches|Plese type the following to confirm: %{codeStart}delete%{codeEnd}.', ), cancelButtonText: __('Cancel'), + actionsToggleText: __('More actions'), }; export default { @@ -33,6 +41,9 @@ export default { GlFormInput, GlSprintf, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { formPath: { type: String, @@ -96,6 +107,10 @@ export default { <template> <div> <gl-disclosure-dropdown + v-gl-tooltip.hover.top="{ + title: $options.i18n.actionsToggleText, + boundary: 'viewport', + }" :toggle-text="$options.i18n.actionsToggleText" text-sr-only icon="ellipsis_v" @@ -153,7 +168,7 @@ export default { <gl-form-input v-model="enteredText" type="text" - size="sm" + width="sm" class="gl-mt-2" aria-labelledby="input-label" autocomplete="off" diff --git a/app/assets/javascripts/branches/components/sort_dropdown.vue b/app/assets/javascripts/branches/components/sort_dropdown.vue index 4866d506988..f4a5b25c4f2 100644 --- a/app/assets/javascripts/branches/components/sort_dropdown.vue +++ b/app/assets/javascripts/branches/components/sort_dropdown.vue @@ -1,6 +1,6 @@ <script> import { GlCollapsibleListbox, GlSearchBoxByClick } from '@gitlab/ui'; -import { mergeUrlParams, visitUrl } from '~/lib/utils/url_utility'; +import { mergeUrlParams, visitUrl, getParameterValues } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; export default { @@ -21,7 +21,7 @@ export default { // own attributes, also in created() data() { return { - searchTerm: '', + searchTerm: getParameterValues('search')[0] || '', }; }, computed: { diff --git a/app/assets/javascripts/ci/admin/jobs_table/components/cells/project_cell.vue b/app/assets/javascripts/ci/admin/jobs_table/components/cells/project_cell.vue index cbb80a5175f..9d516fc267d 100644 --- a/app/assets/javascripts/ci/admin/jobs_table/components/cells/project_cell.vue +++ b/app/assets/javascripts/ci/admin/jobs_table/components/cells/project_cell.vue @@ -23,6 +23,6 @@ export default { </script> <template> <div class="gl-text-truncate"> - <gl-link :href="projectUrl"> {{ projectName }}</gl-link> + <gl-link :href="projectUrl" data-testid="job-project-link">{{ projectName }}</gl-link> </div> </template> diff --git a/app/assets/javascripts/ci/admin/jobs_table/components/cells/runner_cell.vue b/app/assets/javascripts/ci/admin/jobs_table/components/cells/runner_cell.vue index a76829aa129..e44f756a5c5 100644 --- a/app/assets/javascripts/ci/admin/jobs_table/components/cells/runner_cell.vue +++ b/app/assets/javascripts/ci/admin/jobs_table/components/cells/runner_cell.vue @@ -1,5 +1,6 @@ <script> -import { GlLink } from '@gitlab/ui'; +import { GlLink, GlTooltipDirective } from '@gitlab/ui'; +import RunnerTypeIcon from '~/ci/runner/components/runner_type_icon.vue'; import { RUNNER_EMPTY_TEXT, RUNNER_NO_DESCRIPTION } from '../../constants'; export default { @@ -9,6 +10,10 @@ export default { }, components: { GlLink, + RunnerTypeIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, }, props: { job: { @@ -25,15 +30,19 @@ export default { ? this.job.runner.description : this.$options.i18n.noRunnerDescription; }, + runnerType() { + return this.job.runner?.runnerType; + }, }, }; </script> <template> <div class="gl-text-truncate"> - <gl-link v-if="adminUrl" :href="adminUrl"> - {{ description }} - </gl-link> + <span v-if="adminUrl"> + <runner-type-icon :type="runnerType" class="gl-vertical-align-middle" /> + <gl-link :href="adminUrl" data-testid="job-runner-link"> {{ description }} </gl-link> + </span> <span v-else data-testid="empty-runner-text"> {{ $options.i18n.emptyRunnerText }}</span> </div> </template> diff --git a/app/assets/javascripts/ci/admin/jobs_table/constants.js b/app/assets/javascripts/ci/admin/jobs_table/constants.js index ff0efdb1f5b..86c9ab53e75 100644 --- a/app/assets/javascripts/ci/admin/jobs_table/constants.js +++ b/app/assets/javascripts/ci/admin/jobs_table/constants.js @@ -26,9 +26,6 @@ export const DEFAULT_FIELDS_ADMIN = [ { key: 'project', label: __('Project'), columnClass: 'gl-w-20p' }, { key: 'runner', label: __('Runner'), columnClass: 'gl-w-15p' }, { key: 'pipeline', label: __('Pipeline'), columnClass: 'gl-w-10p' }, - { key: 'stage', label: __('Stage'), columnClass: 'gl-w-10p' }, - { key: 'name', label: __('Name'), columnClass: 'gl-w-15p' }, - { key: 'duration', label: __('Duration'), columnClass: 'gl-w-15p' }, { key: 'actions', label: '', columnClass: 'gl-w-10p' }, ]; diff --git a/app/assets/javascripts/ci/admin/jobs_table/graphql/queries/get_all_jobs.query.graphql b/app/assets/javascripts/ci/admin/jobs_table/graphql/queries/get_all_jobs.query.graphql index 89fb1782e46..2e77f4db907 100644 --- a/app/assets/javascripts/ci/admin/jobs_table/graphql/queries/get_all_jobs.query.graphql +++ b/app/assets/javascripts/ci/admin/jobs_table/graphql/queries/get_all_jobs.query.graphql @@ -16,6 +16,7 @@ query getAllJobs( id description adminUrl + runnerType } artifacts { nodes { diff --git a/app/assets/javascripts/ci/artifacts/components/bulk_delete_modal.vue b/app/assets/javascripts/ci/artifacts/components/bulk_delete_modal.vue index 00f5b2eab7d..c27ec0dd500 100644 --- a/app/assets/javascripts/ci/artifacts/components/bulk_delete_modal.vue +++ b/app/assets/javascripts/ci/artifacts/components/bulk_delete_modal.vue @@ -65,6 +65,7 @@ export default { :title="$options.i18n.modalTitle(checkedCount)" :action-primary="modalActionPrimary" :action-cancel="modalActionCancel" + data-testid="artifacts-bulk-delete-modal" v-bind="$attrs" v-on="$listeners" > diff --git a/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue index e08470c62be..d8f9eb65236 100644 --- a/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue +++ b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue @@ -5,16 +5,15 @@ import { GlLink, GlButtonGroup, GlButton, - GlBadge, GlIcon, GlPagination, GlFormCheckbox, GlTooltipDirective, } from '@gitlab/ui'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import { createAlert } from '~/alert'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { TYPENAME_PROJECT } from '~/graphql_shared/constants'; import getJobArtifactsQuery from '../graphql/queries/get_job_artifacts.query.graphql'; import { totalArtifactsSizeForJob, mapArchivesToJobNodes, mapBooleansToJobNodes } from '../utils'; @@ -65,12 +64,11 @@ export default { GlLink, GlButtonGroup, GlButton, - GlBadge, GlIcon, GlPagination, GlFormCheckbox, - CiIcon, TimeAgo, + CiBadgeLink, JobCheckbox, ArtifactsBulkDelete, BulkDeleteModal, @@ -328,7 +326,7 @@ export default { { key: 'artifacts', label: I18N_ARTIFACTS, - thClass: 'gl-w-quarter', + thClass: 'gl-w-eighth', }, { key: 'job', @@ -350,7 +348,7 @@ export default { { key: 'actions', label: '', - thClass: 'gl-w-eighth', + thClass: 'gl-w-20p', tdClass: 'gl-text-right', }, ], @@ -403,6 +401,7 @@ export default { :checked="isAnyVisibleArtifactSelected" :indeterminate="isAnyVisibleArtifactSelected && !areAllVisibleArtifactsSelected" :disabled="isSelectedArtifactsLimitReached && !isAnyVisibleArtifactSelected" + data-testid="select-all-artifacts-checkbox" @change="handleSelectAllChecked" /> </template> @@ -441,45 +440,37 @@ export default { </span> </template> <template #cell(job)="{ item }"> - <span class="gl-display-inline-flex gl-align-items-center gl-w-full gl-mb-4"> + <div class="gl-display-inline-flex gl-align-items-center gl-mb-3 gl-gap-3"> <span data-testid="job-artifacts-job-status"> - <ci-icon v-if="item.succeeded" :status="item.detailedStatus" class="gl-mr-3" /> - <gl-badge - v-else - :icon="item.detailedStatus.icon" - :variant="$options.STATUS_BADGE_VARIANTS[item.detailedStatus.group]" - class="gl-mr-3" - > - {{ item.detailedStatus.label }} - </gl-badge> + <ci-badge-link :status="item.detailedStatus" size="sm" :show-text="false" /> </span> - <gl-link :href="item.webPath" class="gl-font-weight-bold"> + <gl-link :href="item.webPath"> {{ item.name }} </gl-link> - </span> - <span class="gl-display-inline-flex"> + </div> + <div class="gl-mb-1"> <gl-icon name="pipeline" class="gl-mr-2" /> - <gl-link - :href="item.pipeline.path" - class="gl-text-black-normal gl-text-decoration-underline gl-mr-4" - > + <gl-link :href="item.pipeline.path" class="gl-mr-2"> {{ pipelineId(item) }} </gl-link> - <gl-icon name="branch" class="gl-mr-2" /> - <gl-link - :href="item.refPath" - class="gl-text-black-normal gl-text-decoration-underline gl-mr-4" - > - {{ item.refName }} - </gl-link> - <gl-icon name="commit" class="gl-mr-2" /> - <gl-link - :href="item.commitPath" - class="gl-text-black-normal gl-text-decoration-underline gl-mr-4" - > - {{ item.shortSha }} - </gl-link> - </span> + <span class="gl-display-inline-block gl-rounded-base gl-px-2 gl-bg-gray-50"> + <gl-icon name="commit" :size="12" class="gl-mr-2" /> + <gl-link + :href="item.commitPath" + class="gl-text-black-normal gl-font-sm gl-font-monospace" + > + {{ item.shortSha }} + </gl-link> + </span> + </div> + <div> + <span class="gl-display-inline-block gl-rounded-base gl-px-2 gl-bg-gray-50"> + <gl-icon name="branch" :size="12" class="gl-mr-1" /> + <gl-link :href="item.refPath" class="gl-text-black-normal gl-font-sm gl-font-monospace"> + {{ item.refName }} + </gl-link> + </span> + </div> </template> <template #cell(size)="{ item }"> <span data-testid="job-artifacts-size">{{ artifactsSize(item) }}</span> diff --git a/app/assets/javascripts/ci/catalog/components/ci_catalog_home.vue b/app/assets/javascripts/ci/catalog/components/ci_catalog_home.vue new file mode 100644 index 00000000000..5fe7e8286ec --- /dev/null +++ b/app/assets/javascripts/ci/catalog/components/ci_catalog_home.vue @@ -0,0 +1,8 @@ +<script> +export default {}; +</script> +<template> + <div> + <router-view /> + </div> +</template> diff --git a/app/assets/javascripts/ci/catalog/components/details/ci_resource_about.vue b/app/assets/javascripts/ci/catalog/components/details/ci_resource_about.vue new file mode 100644 index 00000000000..572a8183730 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/components/details/ci_resource_about.vue @@ -0,0 +1,120 @@ +<script> +import { GlIcon, GlLink } from '@gitlab/ui'; +import { n__, s__, sprintf } from '~/locale'; +import { formatDate } from '~/lib/utils/datetime_utility'; + +export default { + components: { + GlIcon, + GlLink, + }, + props: { + isLoadingDetails: { + type: Boolean, + required: true, + }, + isLoadingSharedData: { + type: Boolean, + required: true, + }, + openIssuesCount: { + required: false, + type: Number, + default: 0, + }, + openMergeRequestsCount: { + required: false, + type: Number, + default: 0, + }, + latestVersion: { + required: false, + type: Object, + default: () => ({}), + }, + webPath: { + required: false, + type: String, + default: '', + }, + }, + computed: { + hasVersion() { + return this.latestVersion; + }, + lastReleaseText() { + if (this.hasVersion) { + return sprintf(this.$options.i18n.lastRelease, { + date: this.releasedAt, + }); + } + + return this.$options.i18n.lastReleaseMissing; + }, + openIssuesText() { + return n__('%d issue', '%d issues', this.openIssuesCount); + }, + openMergeRequestText() { + return n__('%d merge request', '%d merge requests', this.openMergeRequestsCount); + }, + releasedAt() { + return this.hasVersion && formatDate(this.latestVersion.releasedAt, 'yyyy-mm-dd'); + }, + projectInfoItems() { + return [ + { + icon: 'project', + link: `${this.webPath}`, + text: this.$options.i18n.projectLink, + isLoading: this.isLoadingSharedData, + }, + { + icon: 'issues', + link: `${this.webPath}/issues`, + text: this.openIssuesText, + isLoading: this.isLoadingDetails, + }, + { + icon: 'merge-request', + link: `${this.webPath}/merge_requests`, + text: this.openMergeRequestText, + isLoading: this.isLoadingDetails, + }, + { + icon: 'clock', + text: this.lastReleaseText, + isLoading: this.isLoadingSharedData, + }, + ]; + }, + }, + i18n: { + projectLink: s__('CiCatalog|Go to the project'), + lastRelease: s__('CiCatalog|Last release at %{date}'), + lastReleaseMissing: s__('CiCatalog|No release available'), + }, +}; +</script> + +<template> + <div class="gl-py-2 gl-sm-display-flex gl-gap-5"> + <span + v-for="item in projectInfoItems" + :key="`${item.icon}`" + class="gl-display-flex gl-align-items-center gl-xs-mb-3" + > + <gl-icon class="gl-text-primary gl-mr-2" :name="item.icon" /> + <div + v-if="item.isLoading" + class="gl-animate-skeleton-loader gl-h-4 gl-rounded-base gl-w-15" + data-testid="skeleton-loading-line" + ></div> + <template v-else> + <gl-link v-if="item.link" :href="item.link"> {{ item.text }} </gl-link> + <span v-else class="gl-text-secondary"> + {{ item.text }} + </span> + </template> + </span> + </div> +</template> diff --git a/app/assets/javascripts/ci/catalog/components/details/ci_resource_components.vue b/app/assets/javascripts/ci/catalog/components/details/ci_resource_components.vue new file mode 100644 index 00000000000..85dfa12c756 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/components/details/ci_resource_components.vue @@ -0,0 +1,103 @@ +<script> +import { GlLoadingIcon, GlTableLite } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { __, s__ } from '~/locale'; +import getCiCatalogResourceComponents from '../../graphql/queries/get_ci_catalog_resource_components.query.graphql'; + +export default { + components: { + GlLoadingIcon, + GlTableLite, + }, + props: { + resourceId: { + type: String, + required: true, + }, + }, + data() { + return { + components: [], + }; + }, + apollo: { + components: { + query: getCiCatalogResourceComponents, + variables() { + return { + id: this.resourceId, + }; + }, + update(data) { + return data?.ciCatalogResource?.components?.nodes || []; + }, + error() { + createAlert({ message: this.$options.i18n.fetchError }); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.components.loading; + }, + }, + methods: { + generateSnippet(componentPath) { + // This is not to be translated because it is our CI yaml syntax. + // eslint-disable-next-line @gitlab/require-i18n-strings + return `include: + - component: ${componentPath}`; + }, + humanizeBoolean(bool) { + return bool ? __('Yes') : __('No'); + }, + }, + fields: [ + { + key: 'name', + label: s__('CiCatalogComponent|Parameters'), + thClass: 'gl-w-40p', + }, + { + key: 'defaultValue', + label: s__('CiCatalogComponent|Default Value'), + thClass: 'gl-w-40p', + }, + { + key: 'required', + label: s__('CiCatalogComponent|Mandatory'), + thClass: 'gl-w-20p', + }, + ], + i18n: { + inputTitle: s__('CiCatalogComponent|Inputs'), + fetchError: s__("CiCatalogComponent|There was an error fetching this resource's components"), + }, +}; +</script> + +<template> + <div> + <gl-loading-icon v-if="isLoading" size="lg" /> + <template v-else> + <div + v-for="component in components" + :key="component.id" + class="gl-mb-8" + data-testid="component-section" + > + <h3 class="gl-font-size-h2" data-testid="component-name">{{ component.name }}</h3> + <p class="gl-mt-5">{{ component.description }}</p> + <pre class="gl-w-85p gl-py-4">{{ generateSnippet(component.path) }}</pre> + <div class="gl-mt-5"> + <b class="gl-display-block gl-mb-4"> {{ $options.i18n.inputTitle }}</b> + <gl-table-lite :items="component.inputs.nodes" :fields="$options.fields"> + <template #cell(required)="{ item }"> + {{ humanizeBoolean(item.required) }} + </template> + </gl-table-lite> + </div> + </div> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ci/catalog/components/details/ci_resource_details.vue b/app/assets/javascripts/ci/catalog/components/details/ci_resource_details.vue new file mode 100644 index 00000000000..c0feb52c185 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/components/details/ci_resource_details.vue @@ -0,0 +1,41 @@ +<script> +import { GlTab, GlTabs } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import CiResourceComponents from './ci_resource_components.vue'; +import CiResourceReadme from './ci_resource_readme.vue'; + +export default { + components: { + CiResourceReadme, + CiResourceComponents, + GlTab, + GlTabs, + }, + mixins: [glFeatureFlagsMixin()], + props: { + resourceId: { + type: String, + required: true, + }, + }, + i18n: { + tabs: { + components: s__('CiCatalog|Components'), + readme: s__('CiCatalog|Readme'), + }, + }, +}; +</script> + +<template> + <gl-tabs> + <gl-tab v-if="glFeatures.ciCatalogComponentsTab" :title="$options.i18n.tabs.components" lazy> + <ci-resource-components :resource-id="resourceId" + /></gl-tab> + <gl-tab :title="$options.i18n.tabs.readme" lazy> + <ci-resource-readme :resource-id="resourceId" /> + </gl-tab> + </gl-tabs> +</template> +<style></style> diff --git a/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue b/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue new file mode 100644 index 00000000000..6673785ffd2 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue @@ -0,0 +1,130 @@ +<script> +import { GlAvatar, GlAvatarLink, GlBadge } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { isNumeric } from '~/lib/utils/number_utils'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import CiResourceAbout from './ci_resource_about.vue'; +import CiResourceHeaderSkeletonLoader from './ci_resource_header_skeleton_loader.vue'; + +export default { + components: { + CiBadgeLink, + CiResourceAbout, + CiResourceHeaderSkeletonLoader, + GlAvatar, + GlAvatarLink, + GlBadge, + }, + props: { + isLoadingDetails: { + type: Boolean, + required: true, + }, + isLoadingSharedData: { + type: Boolean, + required: true, + }, + openIssuesCount: { + type: Number, + required: false, + default: 0, + }, + openMergeRequestsCount: { + type: Number, + required: false, + default: 0, + }, + pipelineStatus: { + type: Object, + required: false, + default: () => ({}), + }, + resource: { + type: Object, + required: true, + }, + }, + computed: { + entityId() { + return getIdFromGraphQLId(this.resource.id); + }, + fullPath() { + return `${this.rootNamespace.fullPath}/${this.rootNamespace.name}`; + }, + hasLatestVersion() { + return this.latestVersion?.tagName; + }, + hasPipelineStatus() { + return this.pipelineStatus?.text; + }, + latestVersion() { + return this.resource.latestVersion; + }, + rootNamespace() { + return this.resource.rootNamespace; + }, + versionBadgeText() { + return isNumeric(this.latestVersion.tagName) + ? `v${this.latestVersion.tagName}` + : this.latestVersion.tagName; + }, + }, +}; +</script> +<template> + <div> + <ci-resource-header-skeleton-loader v-if="isLoadingSharedData" class="gl-py-5" /> + <div v-else class="gl-display-flex gl-py-5"> + <gl-avatar-link :href="resource.webPath"> + <gl-avatar + class="gl-mr-4" + :entity-id="entityId" + :entity-name="resource.name" + shape="rect" + :size="64" + :src="resource.icon" + /> + </gl-avatar-link> + <div + class="gl-display-flex gl-flex-direction-column gl-align-items-flex-start gl-justify-content-center" + > + <div class="gl-font-sm gl-text-secondary"> + {{ fullPath }} + </div> + <span class="gl-display-flex"> + <div class="gl-font-lg gl-font-weight-bold">{{ resource.name }}</div> + <gl-badge + v-if="hasLatestVersion" + size="sm" + class="gl-ml-3 gl-my-1" + :href="latestVersion.tagPath" + > + {{ versionBadgeText }} + </gl-badge> + </span> + <ci-badge-link + v-if="hasPipelineStatus" + class="gl-mt-2" + :status="pipelineStatus" + size="sm" + show-text + /> + </div> + </div> + <ci-resource-about + :is-loading-details="isLoadingDetails" + :is-loading-shared-data="isLoadingSharedData" + :open-issues-count="openIssuesCount" + :open-merge-requests-count="openMergeRequestsCount" + :latest-version="latestVersion" + :web-path="resource.webPath" + /> + <div + v-if="isLoadingSharedData" + class="gl-animate-skeleton-loader gl-h-4 gl-rounded-base gl-my-3 gl-max-w-20!" + ></div> + <p v-else class="gl-mt-3"> + {{ resource.description }} + </p> + </div> +</template> diff --git a/app/assets/javascripts/ci/catalog/components/details/ci_resource_header_skeleton_loader.vue b/app/assets/javascripts/ci/catalog/components/details/ci_resource_header_skeleton_loader.vue new file mode 100644 index 00000000000..83ea224d772 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/components/details/ci_resource_header_skeleton_loader.vue @@ -0,0 +1,13 @@ +<script> +export default {}; +</script> + +<template> + <div class="gl-display-flex"> + <div class="gl-animate-skeleton-loader gl-h-11 gl-rounded-base gl-w-11"></div> + <div class="gl-pl-4 gl--flex-center gl-flex-direction-column"> + <div class="gl-animate-skeleton-loader gl-h-4 gl-rounded-base gl-mb-3 gl-w-20"></div> + <div class="gl-animate-skeleton-loader gl-h-4 gl-rounded-base gl-w-20"></div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/catalog/components/details/ci_resource_readme.vue b/app/assets/javascripts/ci/catalog/components/details/ci_resource_readme.vue new file mode 100644 index 00000000000..d473833869d --- /dev/null +++ b/app/assets/javascripts/ci/catalog/components/details/ci_resource_readme.vue @@ -0,0 +1,55 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { __ } from '~/locale'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import getCiCatalogResourceReadme from '../../graphql/queries/get_ci_catalog_resource_readme.query.graphql'; + +export default { + components: { + GlLoadingIcon, + }, + directives: { SafeHtml }, + props: { + resourceId: { + type: String, + required: true, + }, + }, + data() { + return { + readmeHtml: null, + }; + }, + apollo: { + readmeHtml: { + query: getCiCatalogResourceReadme, + variables() { + return { + id: this.resourceId, + }; + }, + update(data) { + return data?.ciCatalogResource?.readmeHtml || null; + }, + error() { + createAlert({ message: this.$options.i18n.loadingError }); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.readmeHtml.loading; + }, + }, + i18n: { + loadingError: __("There was a problem loading this project's readme content."), + }, +}; +</script> +<template> + <div> + <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" /> + <div v-else v-safe-html="readmeHtml"></div> + </div> +</template> diff --git a/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue b/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue new file mode 100644 index 00000000000..487215875c0 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue @@ -0,0 +1,59 @@ +<script> +import { GlBanner, GlLink } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { CATALOG_FEEDBACK_DISMISSED_KEY } from '../../constants'; + +export default { + components: { + GlBanner, + GlLink, + }, + inject: ['pageTitle', 'pageDescription'], + data() { + return { + isFeedbackBannerDismissed: localStorage.getItem(CATALOG_FEEDBACK_DISMISSED_KEY) === 'true', + }; + }, + methods: { + handleDismissBanner() { + localStorage.setItem(CATALOG_FEEDBACK_DISMISSED_KEY, 'true'); + this.isFeedbackBannerDismissed = true; + }, + }, + i18n: { + banner: { + title: __('Your feedback is important to us 👋'), + description: s__( + "CiCatalog|We want to help you create and manage pipeline component repositories, while also making it easier to reuse pipeline configurations. Let us know how we're doing!", + ), + btnText: __('Give us some feedback'), + }, + learnMore: __('Learn more'), + }, + learnMorePath: helpPagePath('ci/components/index'), +}; +</script> +<template> + <div class="gl-border-b-1 gl-border-gray-100 gl-border-b-solid"> + <gl-banner + v-if="!isFeedbackBannerDismissed" + class="gl-mt-5" + :title="$options.i18n.banner.title" + :button-text="$options.i18n.banner.btnText" + button-link="https://gitlab.com/gitlab-org/gitlab/-/issues/407556" + @close="handleDismissBanner" + > + <p> + {{ $options.i18n.banner.description }} + </p> + </gl-banner> + <h1 class="gl-font-size-h-display">{{ pageTitle }}</h1> + <p> + <span>{{ pageDescription }}</span> + <gl-link :href="$options.learnMorePath" target="_blank">{{ + $options.i18n.learnMore + }}</gl-link> + </p> + </div> +</template> diff --git a/app/assets/javascripts/ci/catalog/components/list/catalog_list_skeleton_loader.vue b/app/assets/javascripts/ci/catalog/components/list/catalog_list_skeleton_loader.vue new file mode 100644 index 00000000000..3722b8e6c59 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/components/list/catalog_list_skeleton_loader.vue @@ -0,0 +1,57 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +export default { + components: { + GlSkeletonLoader, + }, + data() { + return { + coordinates: { + statsX: 0, + releaseDateX: 0, + }, + width: 0, + }; + }, + mounted() { + this.setSvgSize(); + }, + methods: { + setSvgSize() { + this.width = this.$el.offsetWidth; + this.coordinates.releaseDateX = this.width - 200; + this.coordinates.statsX = this.width - 90; + }, + }, + skeletonLoadItems: new Array(5), +}; +</script> +<template> + <div class="gl-w-full"> + <gl-skeleton-loader + v-for="(_, index) in $options.skeletonLoadItems" + :key="index" + :height="60" + :width="width" + > + <!-- Catalog project avatar --> + <rect x="0" y="0" width="48" height="48" rx="4" ry="4" /> + <!-- namespace path --> + <rect x="60" y="4" width="400" height="16" rx="2" ry="2" /> + <!-- Project description --> + <rect x="60" y="30" width="500" height="12" rx="2" ry="2" /> + + <!-- Release date line --> + <rect :x="coordinates.releaseDateX" y="30" width="200" height="12" rx="2" ry="2" /> + + <!-- Favorites --> + <rect :x="coordinates.statsX" y="4" width="16" height="16" rx="2" ry="2" /> + <rect :x="coordinates.statsX + 18" y="7" width="18" height="10" rx="2" ry="2" /> + + <!-- Forks --> + <rect :x="coordinates.statsX + 50" y="4" width="16" height="16" rx="2" ry="2" /> + <rect :x="coordinates.statsX + 68" y="7" width="18" height="10" rx="2" ry="2" /> + </gl-skeleton-loader> + </div> +</template> diff --git a/app/assets/javascripts/ci/catalog/components/list/ci_resources_list.vue b/app/assets/javascripts/ci/catalog/components/list/ci_resources_list.vue new file mode 100644 index 00000000000..d1fd9fe977b --- /dev/null +++ b/app/assets/javascripts/ci/catalog/components/list/ci_resources_list.vue @@ -0,0 +1,74 @@ +<script> +import { GlKeysetPagination } from '@gitlab/ui'; + +import { s__, sprintf } from '~/locale'; +import { ciCatalogResourcesItemsCount } from '../../graphql/settings'; +import CiResourcesListItem from './ci_resources_list_item.vue'; + +export default { + components: { + CiResourcesListItem, + GlKeysetPagination, + }, + props: { + currentPage: { + type: Number, + required: true, + }, + pageInfo: { + type: Object, + required: true, + }, + resources: { + type: Array, + required: true, + }, + totalCount: { + type: Number, + required: true, + }, + }, + computed: { + showPageCount() { + return typeof this.totalPageCount === 'number' && this.totalPageCount > 0; + }, + totalPageCount() { + return Math.ceil(this.totalCount / ciCatalogResourcesItemsCount); + }, + pageText() { + return sprintf(this.$options.i18n.pageText, { + currentPage: this.currentPage, + totalPage: this.totalPageCount, + }); + }, + }, + i18n: { + pageText: s__('CiCatalog|Page %{currentPage} of %{totalPage}'), + }, +}; +</script> +<template> + <div> + <ul class="gl-p-0" data-testId="catalog-list-container"> + <ci-resources-list-item + v-for="resource in resources" + :key="resource.id" + :resource="resource" + /> + </ul> + <div class="gl-display-flex gl-justify-content-center"> + <gl-keyset-pagination + v-bind="pageInfo" + @prev="$emit('onPrevPage')" + @next="$emit('onNextPage')" + /> + </div> + <div + v-if="showPageCount" + class="gl-display-flex gl-justify-content-center gl-mt-3" + data-testid="pageCount" + > + {{ pageText }} + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/catalog/components/list/ci_resources_list_item.vue b/app/assets/javascripts/ci/catalog/components/list/ci_resources_list_item.vue new file mode 100644 index 00000000000..63243539575 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/components/list/ci_resources_list_item.vue @@ -0,0 +1,144 @@ +<script> +import { + GlAvatar, + GlBadge, + GlButton, + GlIcon, + GlLink, + GlSprintf, + GlTooltipDirective, +} from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { formatDate, getTimeago } from '~/lib/utils/datetime_utility'; +import { CI_RESOURCE_DETAILS_PAGE_NAME } from '../../router/constants'; + +export default { + i18n: { + unreleased: s__('CiCatalog|Unreleased'), + releasedMessage: s__('CiCatalog|Released %{timeAgo} by %{author}'), + }, + components: { + GlAvatar, + GlBadge, + GlButton, + GlIcon, + GlLink, + GlSprintf, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + resource: { + type: Object, + required: true, + }, + }, + computed: { + authorName() { + return this.latestVersion.author.name; + }, + authorProfileUrl() { + return this.latestVersion.author.webUrl; + }, + entityId() { + return getIdFromGraphQLId(this.resource.id); + }, + starCount() { + return this.resource?.starCount || 0; + }, + forksCount() { + return this.resource?.forksCount || 0; + }, + hasReleasedVersion() { + return Boolean(this.latestVersion?.releasedAt); + }, + formattedDate() { + return formatDate(this.latestVersion?.releasedAt); + }, + latestVersion() { + return this.resource?.latestVersion || {}; + }, + releasedAt() { + return getTimeago().format(this.latestVersion?.releasedAt); + }, + resourcePath() { + return `${this.resource.rootNamespace?.name} / ${this.resource.rootNamespace?.fullPath} / `; + }, + tagName() { + return this.latestVersion?.tagName || this.$options.i18n.unreleased; + }, + }, + methods: { + navigateToDetailsPage() { + this.$router.push({ + name: CI_RESOURCE_DETAILS_PAGE_NAME, + params: { id: this.entityId }, + }); + }, + }, +}; +</script> +<template> + <li + class="gl-display-flex gl-display-flex-wrap gl-border-b-1 gl-border-gray-100 gl-border-b-solid gl-text-gray-500 gl-py-3" + data-testid="catalog-resource-item" + > + <gl-avatar + class="gl-mr-4" + :entity-id="entityId" + :entity-name="resource.name" + shape="rect" + :size="48" + :src="resource.icon" + @click="navigateToDetailsPage" + /> + <div class="gl-display-flex gl-flex-direction-column gl-flex-grow-1"> + <div class="gl-display-flex gl-flex-wrap gl-gap-2 gl-mb-2"> + <gl-button + variant="link" + class="gl-text-gray-900! gl-mr-1" + data-testid="ci-resource-link" + @click="navigateToDetailsPage" + > + {{ resourcePath }} <b> {{ resource.name }}</b> + </gl-button> + <div class="gl-display-flex gl-flex-grow-1 gl-md-justify-content-space-between"> + <gl-badge size="sm">{{ tagName }}</gl-badge> + <span class="gl-display-flex gl-align-items-center gl-ml-5"> + <span class="gl--flex-center" data-testid="stats-favorites"> + <gl-icon name="star" :size="14" class="gl-mr-1" /> + <span class="gl-mr-3">{{ starCount }}</span> + </span> + <span class="gl--flex-center" data-testid="stats-forks"> + <gl-icon name="fork" :size="14" class="gl-mr-1" /> + <span>{{ forksCount }}</span> + </span> + </span> + </div> + </div> + <div class="gl-display-flex gl-sm-flex-direction-column gl-justify-content-space-between"> + <span class="gl-display-flex gl-flex-basis-two-thirds gl-font-sm">{{ + resource.description + }}</span> + <div class="gl-display-flex gl-justify-content-end"> + <span v-if="hasReleasedVersion"> + <gl-sprintf :message="$options.i18n.releasedMessage"> + <template #timeAgo> + <span v-gl-tooltip.bottom :title="formattedDate"> + {{ releasedAt }} + </span> + </template> + <template #author> + <gl-link :href="authorProfileUrl" data-testid="user-link"> + <span>{{ authorName }}</span> + </gl-link> + </template> + </gl-sprintf> + </span> + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/ci/catalog/components/list/empty_state.vue b/app/assets/javascripts/ci/catalog/components/list/empty_state.vue new file mode 100644 index 00000000000..a53ddefaa50 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/components/list/empty_state.vue @@ -0,0 +1,22 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('CiCatalog|Get started with the CI/CD Catalog'), + description: s__( + 'CiCatalog|Create a pipeline component repository and make reusing pipeline configurations faster and easier.', + ), + }, + name: 'CiCatalogEmptyState', + components: { + GlEmptyState, + }, +}; +</script> +<template> + <div> + <gl-empty-state :title="$options.i18n.title" :description="$options.i18n.description" /> + </div> +</template> diff --git a/app/assets/javascripts/ci/catalog/components/pages/ci_resource_details_page.vue b/app/assets/javascripts/ci/catalog/components/pages/ci_resource_details_page.vue new file mode 100644 index 00000000000..da2c73be900 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/components/pages/ci_resource_details_page.vue @@ -0,0 +1,109 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { createAlert } from '~/alert'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { CI_CATALOG_RESOURCE_TYPE } from '../../graphql/settings'; +import getCatalogCiResourceDetails from '../../graphql/queries/get_ci_catalog_resource_details.query.graphql'; +import getCatalogCiResourceSharedData from '../../graphql/queries/get_ci_catalog_resource_shared_data.query.graphql'; +import CiResourceDetails from '../details/ci_resource_details.vue'; +import CiResourceHeader from '../details/ci_resource_header.vue'; + +export default { + components: { + CiResourceDetails, + CiResourceHeader, + GlEmptyState, + }, + inject: ['ciCatalogPath'], + data() { + return { + isEmpty: false, + resourceSharedData: {}, + resourceAdditionalDetails: {}, + }; + }, + apollo: { + resourceSharedData: { + query: getCatalogCiResourceSharedData, + variables() { + return { + id: this.graphQLId, + }; + }, + update(data) { + return data.ciCatalogResource; + }, + error(e) { + this.isEmpty = true; + createAlert({ message: e.message }); + }, + }, + resourceAdditionalDetails: { + query: getCatalogCiResourceDetails, + variables() { + return { + id: this.graphQLId, + }; + }, + update(data) { + return data.ciCatalogResource; + }, + error(e) { + this.isEmpty = true; + createAlert({ message: e.message }); + }, + }, + }, + computed: { + graphQLId() { + return convertToGraphQLId(CI_CATALOG_RESOURCE_TYPE, this.$route.params.id); + }, + isLoadingDetails() { + return this.$apollo.queries.resourceAdditionalDetails.loading; + }, + isLoadingSharedData() { + return this.$apollo.queries.resourceSharedData.loading; + }, + versions() { + return this.resourceAdditionalDetails?.versions?.nodes || []; + }, + pipelineStatus() { + return ( + this.resourceAdditionalDetails?.versions?.nodes[0]?.commit?.pipelines?.nodes[0] + ?.detailedStatus || null + ); + }, + }, + i18n: { + emptyStateTitle: s__('CiCatalog|No component available'), + emptyStateDescription: s__( + 'CiCatalog|Component ID not found, or you do not have permission to access component.', + ), + emptyStateButtonText: s__('CiCatalog|Back to the CI/CD Catalog'), + }, +}; +</script> +<template> + <div> + <div v-if="isEmpty" class="gl-display-flex"> + <gl-empty-state + :title="$options.i18n.emptyStateTitle" + :description="$options.i18n.emptyStateDescription" + :primary-button-text="$options.i18n.emptyStateButtonText" + :primary-button-link="ciCatalogPath" + /> + </div> + <div v-else> + <ci-resource-header + :open-issues-count="resourceAdditionalDetails.openIssuesCount" + :open-merge-requests-count="resourceAdditionalDetails.openMergeRequestsCount" + :is-loading-details="isLoadingDetails" + :is-loading-shared-data="isLoadingSharedData" + :pipeline-status="pipelineStatus" + :resource="resourceSharedData" + /> + <ci-resource-details :resource-id="graphQLId" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/catalog/constants.js b/app/assets/javascripts/ci/catalog/constants.js new file mode 100644 index 00000000000..ab067f991cd --- /dev/null +++ b/app/assets/javascripts/ci/catalog/constants.js @@ -0,0 +1,35 @@ +// We disable this for the entire file until the mock data is cleanup +/* eslint-disable @gitlab/require-i18n-strings */ +export const CATALOG_FEEDBACK_DISMISSED_KEY = 'catalog_feedback_dismissed'; + +export const componentsMockData = { + __typename: 'CiComponentConnection', + nodes: [ + { + id: 'gid://gitlab/Ci::Component/1', + name: 'Ruby gal', + description: 'This is a pretty amazing component that does EVERYTHING ruby.', + path: 'gitlab.com/gitlab-org/ruby-gal@~latest', + inputs: { nodes: [{ name: 'version', defaultValue: '1.0.0', required: true }] }, + }, + { + id: 'gid://gitlab/Ci::Component/2', + name: 'Javascript madness', + description: 'Adds some spice to your life.', + path: 'gitlab.com/gitlab-org/javascript-madness@~latest', + inputs: { + nodes: [ + { name: 'isFun', defaultValue: 'true', required: true }, + { name: 'RandomNumber', defaultValue: '10', required: false }, + ], + }, + }, + { + id: 'gid://gitlab/Ci::Component/3', + name: 'Go go go', + description: 'When you write Go, you gotta go go go.', + path: 'gitlab.com/gitlab-org/go-go-go@~latest', + inputs: { nodes: [{ name: 'version', defaultValue: '1.0.0', required: true }] }, + }, + ], +}; diff --git a/app/assets/javascripts/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql b/app/assets/javascripts/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql new file mode 100644 index 00000000000..f4d1bb0eaaf --- /dev/null +++ b/app/assets/javascripts/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql @@ -0,0 +1,25 @@ +fragment CatalogResourceFields on CiCatalogResource { + id + icon + name + description + starCount + forksCount + latestVersion { + id + tagName + tagPath + releasedAt + author { + id + name + webUrl + } + } + rootNamespace { + id + fullPath + name + } + webPath +} diff --git a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_components.query.graphql b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_components.query.graphql new file mode 100644 index 00000000000..6aef5dcc4e7 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_components.query.graphql @@ -0,0 +1,20 @@ +query getCiCatalogResourceComponents($id: CiCatalogResourceID!) { + ciCatalogResource(id: $id) { + id + components @client { + nodes { + id + name + description + path + inputs { + nodes { + name + defaultValue + required + } + } + } + } + } +} diff --git a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql new file mode 100644 index 00000000000..382d3866795 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql @@ -0,0 +1,29 @@ +query getCiCatalogResourceDetails($id: CiCatalogResourceID!) { + ciCatalogResource(id: $id) { + id + openIssuesCount + openMergeRequestsCount + versions(first: 1) { + nodes { + id + commit { + id + pipelines(first: 1) { + nodes { + id + detailedStatus { + id + detailsPath + icon + text + group + } + } + } + } + tagName + releasedAt + } + } + } +} diff --git a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_readme.query.graphql b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_readme.query.graphql new file mode 100644 index 00000000000..6b3d0cdcfc7 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_readme.query.graphql @@ -0,0 +1,6 @@ +query getCiCatalogResourceReadme($id: CiCatalogResourceID!) { + ciCatalogResource(id: $id) { + id + readmeHtml + } +} diff --git a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_shared_data.query.graphql b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_shared_data.query.graphql new file mode 100644 index 00000000000..4ac4cb0e394 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resource_shared_data.query.graphql @@ -0,0 +1,7 @@ +#import "../fragments/catalog_resource.fragment.graphql" + +query getCiCatalogResourceSharedData($id: CiCatalogResourceID!) { + ciCatalogResource(id: $id) { + ...CatalogResourceFields + } +} diff --git a/app/assets/javascripts/ci/catalog/graphql/settings.js b/app/assets/javascripts/ci/catalog/graphql/settings.js new file mode 100644 index 00000000000..a87b26ca4fc --- /dev/null +++ b/app/assets/javascripts/ci/catalog/graphql/settings.js @@ -0,0 +1,32 @@ +import { componentsMockData } from '../constants'; + +export const ciCatalogResourcesItemsCount = 20; +export const CI_CATALOG_RESOURCE_TYPE = 'Ci::Catalog::Resource'; + +export const cacheConfig = { + cacheConfig: { + typePolicies: { + Query: { + fields: { + ciCatalogResource(_, { args, toReference }) { + return toReference({ + __typename: 'CiCatalogResource', + id: args.id, + }); + }, + ciCatalogResources: { + keyArgs: false, + }, + }, + }, + }, + }, +}; + +export const resolvers = { + CiCatalogResource: { + components() { + return componentsMockData; + }, + }, +}; diff --git a/app/assets/javascripts/ci/catalog/router/constants.js b/app/assets/javascripts/ci/catalog/router/constants.js new file mode 100644 index 00000000000..2d9462ef403 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/router/constants.js @@ -0,0 +1,2 @@ +export const CI_RESOURCES_PAGE_NAME = 'ci_resources'; +export const CI_RESOURCE_DETAILS_PAGE_NAME = 'ci_resources_details'; diff --git a/app/assets/javascripts/ci/catalog/router/index.js b/app/assets/javascripts/ci/catalog/router/index.js new file mode 100644 index 00000000000..0b2b3dd3aa3 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/router/index.js @@ -0,0 +1,13 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import { createRoutes } from './routes'; + +Vue.use(VueRouter); + +export const createRouter = (base, listComponent) => { + return new VueRouter({ + base, + mode: 'history', + routes: createRoutes(listComponent), + }); +}; diff --git a/app/assets/javascripts/ci/catalog/router/routes.js b/app/assets/javascripts/ci/catalog/router/routes.js new file mode 100644 index 00000000000..ccfb0673c83 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/router/routes.js @@ -0,0 +1,9 @@ +import CiResourceDetailsPage from '../components/pages/ci_resource_details_page.vue'; +import { CI_RESOURCES_PAGE_NAME, CI_RESOURCE_DETAILS_PAGE_NAME } from './constants'; + +export const createRoutes = (listComponent) => { + return [ + { name: CI_RESOURCES_PAGE_NAME, path: '', component: listComponent }, + { name: CI_RESOURCE_DETAILS_PAGE_NAME, path: '/:id', component: CiResourceDetailsPage }, + ]; +}; diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue index a25f871ac92..77af643cbb3 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue @@ -24,10 +24,6 @@ export default { type: Array, required: true, }, - hasEnvScopeQuery: { - type: Boolean, - required: true, - }, selectedEnvironmentScope: { type: String, required: false, @@ -36,6 +32,7 @@ export default { }, data() { return { + customEnvScope: null, isDropdownShown: false, selectedEnvironment: '', searchTerm: '', @@ -45,42 +42,38 @@ export default { composedCreateButtonLabel() { return sprintf(__('Create wildcard: %{searchTerm}'), { searchTerm: this.searchTerm }); }, - filteredEnvironments() { - const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); - return this.environments.filter((environment) => { - return environment.toLowerCase().includes(lowerCasedSearchTerm); - }); - }, isDropdownLoading() { - return this.areEnvironmentsLoading && this.hasEnvScopeQuery && !this.isDropdownShown; + return this.areEnvironmentsLoading && !this.isDropdownShown; }, isDropdownSearching() { - return this.areEnvironmentsLoading && this.hasEnvScopeQuery && this.isDropdownShown; + return this.areEnvironmentsLoading && this.isDropdownShown; }, searchedEnvironments() { - // If hasEnvScopeQuery (applies only to projects for now), search query will be fired so this - // component will already receive filtered environments during the refetch. - // Otherwise (applies to groups), search the existing list of environments in the frontend - let filtered = this.hasEnvScopeQuery ? this.environments : this.filteredEnvironments; + let filtered = this.environments; // If there is no search term, make sure to include * - if (this.hasEnvScopeQuery && !this.searchTerm) { + if (!this.searchTerm) { filtered = uniq([...filtered, '*']); } + // add custom env scope if it matches the search term + if (this.customEnvScope && this.customEnvScope.startsWith(this.searchTerm)) { + filtered = uniq([...filtered, this.customEnvScope]); + } + return filtered.sort().map((environment) => ({ value: environment, text: environment, })); }, shouldRenderCreateButton() { - return this.searchTerm && !this.environments.includes(this.searchTerm); - }, - shouldRenderDivider() { return ( - (this.hasEnvScopeQuery || this.shouldRenderCreateButton) && !this.areEnvironmentsLoading + this.searchTerm && ![...this.environments, this.customEnvScope].includes(this.searchTerm) ); }, + shouldRenderDivider() { + return !this.areEnvironmentsLoading; + }, environmentScopeLabel() { return convertEnvironmentScope(this.selectedEnvironmentScope); }, @@ -89,16 +82,14 @@ export default { debouncedSearch: debounce(function debouncedSearch(searchTerm) { const newSearchTerm = searchTerm.trim(); this.searchTerm = newSearchTerm; - if (this.hasEnvScopeQuery) { - this.$emit('search-environment-scope', newSearchTerm); - } + this.$emit('search-environment-scope', newSearchTerm); }, 500), selectEnvironment(selected) { this.$emit('select-environment', selected); this.selectedEnvironment = selected; }, createEnvironmentScope() { - this.$emit('create-environment-scope', this.searchTerm); + this.customEnvScope = this.searchTerm; this.selectEnvironment(this.searchTerm); }, toggleDropdownShown(isShown) { @@ -129,7 +120,7 @@ export default { > <template #footer> <gl-dropdown-divider v-if="shouldRenderDivider" /> - <div v-if="hasEnvScopeQuery" data-testid="max-envs-notice"> + <div data-testid="max-envs-notice"> <gl-dropdown-item class="gl-list-style-none" disabled> <gl-sprintf :message="$options.i18n.maxEnvsNote" class="gl-font-sm"> <template #limit> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue index c609e05bbb7..a32c5f476fb 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue @@ -11,9 +11,11 @@ import { GlFormTextarea, GlIcon, GlLink, + GlModal, + GlModalDirective, GlSprintf, } from '@gitlab/ui'; -import { __, s__ } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; import { helpPagePath } from '~/helpers/help_page_helper'; @@ -36,10 +38,11 @@ import { awsTokenList } from './ci_variable_autocomplete_tokens'; const trackingMixin = Tracking.mixin({ label: DRAWER_EVENT_LABEL }); export const i18n = { - addVariable: s__('CiVariables|Add Variable'), + addVariable: s__('CiVariables|Add variable'), cancel: __('Cancel'), defaultScope: allEnvironments.text, - editVariable: s__('CiVariables|Edit Variable'), + deleteVariable: s__('CiVariables|Delete variable'), + editVariable: s__('CiVariables|Edit variable'), environments: __('Environments'), environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE, expandedField: s__('CiVariables|Expand variable reference'), @@ -51,6 +54,7 @@ export const i18n = { maskedDescription: s__( 'CiVariables|Variable will be masked in job logs. Requires values to meet regular expression requirements.', ), + modalDeleteMessage: s__('CiVariables|Do you want to delete the variable %{key}?'), protectedField: s__('CiVariables|Protect variable'), protectedDescription: s__( 'CiVariables|Export variable to pipelines running on protected branches and tags only.', @@ -86,8 +90,12 @@ export default { GlFormTextarea, GlIcon, GlLink, + GlModal, GlSprintf, }, + directives: { + GlModalDirective, + }, mixins: [trackingMixin], inject: ['environmentScopeLink', 'isProtectedByDefault', 'maskableRawRegex', 'maskableRegex'], props: { @@ -170,6 +178,9 @@ export default { modalActionText() { return this.isEditing ? this.$options.i18n.editVariable : this.$options.i18n.addVariable; }, + removeVariableMessage() { + return sprintf(this.$options.i18n.modalDeleteMessage, { key: this.variable.key }); + }, }, watch: { variable: { @@ -188,6 +199,13 @@ export default { close() { this.$emit('close-form'); }, + deleteVariable() { + this.$emit('delete-variable', this.variable); + this.close(); + }, + setEnvironmentScope(scope) { + this.variable = { ...this.variable, environmentScope: scope }; + }, getTrackingErrorProperty() { if (this.isValueEmpty) { return null; @@ -225,164 +243,206 @@ export default { }), i18n, variableOptions, + deleteModal: { + actionPrimary: { + text: __('Delete'), + attributes: { + variant: 'danger', + }, + }, + actionSecondary: { + text: __('Cancel'), + attributes: { + variant: 'default', + }, + }, + }, }; </script> <template> - <gl-drawer - open - data-testid="ci-variable-drawer" - :header-height="getDrawerHeaderHeight" - :z-index="$options.DRAWER_Z_INDEX" - @close="close" - > - <template #title> - <h2 class="gl-m-0">{{ modalActionText }}</h2> - </template> - <gl-form-group - :label="$options.i18n.type" - label-for="ci-variable-type" - class="gl-border-none" - :class="{ - 'gl-mb-n5': !hideEnvironmentScope, - 'gl-mb-n1': hideEnvironmentScope, - }" + <div> + <gl-drawer + open + data-testid="ci-variable-drawer" + :header-height="getDrawerHeaderHeight" + :z-index="$options.DRAWER_Z_INDEX" + @close="close" > - <gl-form-select - id="ci-variable-type" - v-model="variable.variableType" - :options="$options.variableOptions" - /> - </gl-form-group> - <gl-form-group - v-if="!hideEnvironmentScope" - class="gl-border-none gl-mb-n5" - label-for="ci-variable-env" - data-testid="environment-scope" - > - <template #label> - <div class="gl-display-flex gl-align-items-center"> - <span class="gl-mr-2"> - {{ $options.i18n.environments }} - </span> - <gl-link - class="gl-display-flex" - :title="$options.i18n.environmentScopeLinkTitle" - :href="environmentScopeLink" - target="_blank" - data-testid="environment-scope-link" - > - <gl-icon name="question-o" :size="14" /> - </gl-link> - </div> + <template #title> + <h2 class="gl-m-0">{{ modalActionText }}</h2> </template> - <ci-environments-dropdown - v-if="areScopedVariablesAvailable" - class="gl-mb-5" - has-env-scope-query - :are-environments-loading="areEnvironmentsLoading" - :environments="environments" - :selected-environment-scope="variable.environmentScope" - /> - <gl-form-input - v-else - :value="$options.i18n.defaultScope" - class="gl-w-full gl-mb-5" - readonly + <gl-form-group + :label="$options.i18n.type" + label-for="ci-variable-type" + class="gl-border-none" + :class="{ + 'gl-mb-n5': !hideEnvironmentScope, + 'gl-mb-n1': hideEnvironmentScope, + }" + > + <gl-form-select + id="ci-variable-type" + v-model="variable.variableType" + :options="$options.variableOptions" + /> + </gl-form-group> + <gl-form-group + v-if="!hideEnvironmentScope" + class="gl-border-none gl-mb-n5" + label-for="ci-variable-env" + data-testid="environment-scope" + > + <template #label> + <div class="gl-display-flex gl-align-items-center"> + <span class="gl-mr-2"> + {{ $options.i18n.environments }} + </span> + <gl-link + class="gl-display-flex" + :title="$options.i18n.environmentScopeLinkTitle" + :href="environmentScopeLink" + target="_blank" + data-testid="environment-scope-link" + > + <gl-icon name="question-o" :size="14" /> + </gl-link> + </div> + </template> + <ci-environments-dropdown + v-if="areScopedVariablesAvailable" + class="gl-mb-5" + :are-environments-loading="areEnvironmentsLoading" + :environments="environments" + :selected-environment-scope="variable.environmentScope" + @select-environment="setEnvironmentScope" + @search-environment-scope="$emit('search-environment-scope', $event)" + /> + <gl-form-input + v-else + :value="$options.i18n.defaultScope" + class="gl-w-full gl-mb-5" + readonly + /> + </gl-form-group> + <gl-form-group class="gl-border-none gl-mb-n8"> + <template #label> + <div class="gl-display-flex gl-align-items-center gl-mb-n3"> + <span class="gl-mr-2"> + {{ $options.i18n.flags }} + </span> + <gl-link + class="gl-display-flex" + :title="$options.i18n.flagsLinkTitle" + :href="$options.flagLink" + target="_blank" + > + <gl-icon name="question-o" :size="14" /> + </gl-link> + </div> + </template> + <gl-form-checkbox v-model="variable.protected" data-testid="ci-variable-protected-checkbox"> + {{ $options.i18n.protectedField }} + <p class="gl-text-secondary"> + {{ $options.i18n.protectedDescription }} + </p> + </gl-form-checkbox> + <gl-form-checkbox v-model="variable.masked" data-testid="ci-variable-masked-checkbox"> + {{ $options.i18n.maskedField }} + <p class="gl-text-secondary">{{ $options.i18n.maskedDescription }}</p> + </gl-form-checkbox> + <gl-form-checkbox + data-testid="ci-variable-expanded-checkbox" + :checked="isExpanded" + @change="setRaw" + > + {{ $options.i18n.expandedField }} + <p class="gl-text-secondary"> + <gl-sprintf :message="$options.i18n.expandedDescription" class="gl-text-secondary"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + </gl-form-checkbox> + </gl-form-group> + <gl-form-combobox + v-model="variable.key" + :token-list="$options.awsTokenList" + :label-text="$options.i18n.key" + class="gl-border-none gl-pb-0! gl-mb-n5" + data-testid="ci-variable-key" + data-qa-selector="ci_variable_key_field" /> - </gl-form-group> - <gl-form-group class="gl-border-none gl-mb-n8"> - <template #label> - <div class="gl-display-flex gl-align-items-center gl-mb-n3"> - <span class="gl-mr-2"> - {{ $options.i18n.flags }} - </span> - <gl-link - class="gl-display-flex" - :title="$options.i18n.flagsLinkTitle" - :href="$options.flagLink" - target="_blank" - > - <gl-icon name="question-o" :size="14" /> - </gl-link> - </div> - </template> - <gl-form-checkbox v-model="variable.protected" data-testid="ci-variable-protected-checkbox"> - {{ $options.i18n.protectedField }} - <p class="gl-text-secondary"> - {{ $options.i18n.protectedDescription }} - </p> - </gl-form-checkbox> - <gl-form-checkbox v-model="variable.masked" data-testid="ci-variable-masked-checkbox"> - {{ $options.i18n.maskedField }} - <p class="gl-text-secondary">{{ $options.i18n.maskedDescription }}</p> - </gl-form-checkbox> - <gl-form-checkbox - data-testid="ci-variable-expanded-checkbox" - :checked="isExpanded" - @change="setRaw" + <gl-form-group + :label="$options.i18n.value" + label-for="ci-variable-value" + class="gl-border-none gl-mb-n2" + data-testid="ci-variable-value-label" + :invalid-feedback="maskedReqsNotMetText" + :state="isValueValid" > - {{ $options.i18n.expandedField }} - <p class="gl-text-secondary"> - <gl-sprintf :message="$options.i18n.expandedDescription" class="gl-text-secondary"> - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - </gl-sprintf> + <gl-form-textarea + id="ci-variable-value" + v-model="variable.value" + class="gl-border-none gl-font-monospace!" + rows="3" + max-rows="10" + data-testid="ci-variable-value" + data-qa-selector="ci_variable_value_field" + spellcheck="false" + /> + <p + v-if="variable.raw" + class="gl-mt-2 gl-mb-0 text-secondary" + data-testid="raw-variable-tip" + > + {{ $options.i18n.valueFeedback.rawHelpText }} </p> - </gl-form-checkbox> - </gl-form-group> - <gl-form-combobox - v-model="variable.key" - :token-list="$options.awsTokenList" - :label-text="$options.i18n.key" - class="gl-border-none gl-pb-0! gl-mb-n5" - data-testid="ci-variable-key" - data-qa-selector="ci_variable_key_field" - /> - <gl-form-group - :label="$options.i18n.value" - label-for="ci-variable-value" - class="gl-border-none gl-mb-n2" - data-testid="ci-variable-value-label" - :invalid-feedback="maskedReqsNotMetText" - :state="isValueValid" - > - <gl-form-textarea - id="ci-variable-value" - v-model="variable.value" - class="gl-border-none gl-font-monospace!" - rows="3" - max-rows="10" - data-testid="ci-variable-value" - data-qa-selector="ci_variable_value_field" - spellcheck="false" - /> - <p v-if="variable.raw" class="gl-mt-2 gl-mb-0 text-secondary" data-testid="raw-variable-tip"> - {{ $options.i18n.valueFeedback.rawHelpText }} - </p> - </gl-form-group> - <gl-alert - v-if="hasVariableReference" - :title="$options.i18n.variableReferenceTitle" - :dismissible="false" - variant="warning" - class="gl-mx-4 gl-pl-9! gl-border-bottom-0" - data-testid="has-variable-reference-alert" + </gl-form-group> + <gl-alert + v-if="hasVariableReference" + :title="$options.i18n.variableReferenceTitle" + :dismissible="false" + variant="warning" + class="gl-mx-4 gl-pl-9! gl-border-bottom-0" + data-testid="has-variable-reference-alert" + > + {{ $options.i18n.variableReferenceDescription }} + </gl-alert> + <div class="gl-display-flex gl-justify-content-end"> + <gl-button category="secondary" class="gl-mr-3" data-testid="cancel-button" @click="close" + >{{ $options.i18n.cancel }} + </gl-button> + <gl-button + v-if="isEditing" + v-gl-modal-directive="`delete-variable-${variable.key}`" + variant="danger" + category="secondary" + class="gl-mr-3" + data-testid="ci-variable-delete-btn" + >{{ $options.i18n.deleteVariable }}</gl-button + > + <gl-button + category="primary" + variant="confirm" + :disabled="!canSubmit" + data-testid="ci-variable-confirm-btn" + data-qa-selector="ci_variable_save_button" + @click="submit" + >{{ modalActionText }} + </gl-button> + </div> + </gl-drawer> + <gl-modal + ref="modal" + :modal-id="`delete-variable-${variable.key}`" + :title="$options.i18n.deleteVariable" + :action-primary="$options.deleteModal.actionPrimary" + :action-secondary="$options.deleteModal.actionSecondary" + data-testid="ci-variable-drawer-confirm-delete-modal" + @primary="deleteVariable" > - {{ $options.i18n.variableReferenceDescription }} - </gl-alert> - <div class="gl-display-flex gl-justify-content-end"> - <gl-button category="secondary" class="gl-mr-3" data-testid="cancel-button" @click="close" - >{{ $options.i18n.cancel }} - </gl-button> - <gl-button - category="primary" - variant="confirm" - :disabled="!canSubmit" - data-testid="ci-variable-confirm-btn" - @click="submit" - >{{ modalActionText }} - </gl-button> - </div> - </gl-drawer> + {{ removeVariableMessage }} + </gl-modal> + </div> </template> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue index 86c0f34215e..cc664d76267 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue @@ -38,7 +38,6 @@ import { VARIABLE_ACTIONS, variableOptions, } from '../constants'; -import { createJoinedEnvironments } from '../utils'; import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens'; @@ -90,10 +89,6 @@ export default { required: false, default: false, }, - hasEnvScopeQuery: { - type: Boolean, - required: true, - }, mode: { type: String, required: true, @@ -147,13 +142,6 @@ export default { isTipVisible() { return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key); }, - environmentsList() { - if (this.hasEnvScopeQuery) { - return this.environments; - } - - return createJoinedEnvironments(this.variables, this.environments, this.newEnvironments); - }, maskedFeedback() { return this.displayMaskedError ? __('This variable value does not meet the masking requirements.') @@ -211,9 +199,6 @@ export default { addVariable() { this.$emit('add-variable', this.variable); }, - createEnvironmentScope(env) { - this.newEnvironments.push(env); - }, deleteVariable() { this.$emit('delete-variable', this.variable); }, @@ -407,11 +392,9 @@ export default { <ci-environments-dropdown v-if="areScopedVariablesAvailable" :are-environments-loading="areEnvironmentsLoading" - :has-env-scope-query="hasEnvScopeQuery" :selected-environment-scope="variable.environmentScope" - :environments="environmentsList" + :environments="environments" @select-environment="setEnvironmentScope" - @create-environment-scope="createEnvironmentScope" @search-environment-scope="$emit('search-environment-scope', $event)" /> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue index 482f6da5617..f2d81b3f271 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue @@ -37,10 +37,6 @@ export default { required: false, default: false, }, - hasEnvScopeQuery: { - type: Boolean, - required: true, - }, isLoading: { type: Boolean, required: false, @@ -125,7 +121,6 @@ export default { :are-environments-loading="areEnvironmentsLoading" :are-scoped-variables-available="areScopedVariablesAvailable" :environments="environments" - :has-env-scope-query="hasEnvScopeQuery" :hide-environment-scope="hideEnvironmentScope" :variables="variables" :mode="mode" @@ -144,8 +139,11 @@ export default { :hide-environment-scope="hideEnvironmentScope" :selected-variable="selectedVariable" :mode="mode" - v-on="$listeners" + @add-variable="addVariable" + @delete-variable="deleteVariable" @close-form="closeForm" + @update-variable="updateVariable" + @search-environment-scope="$emit('search-environment-scope', $event)" /> </div> </div> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue index 3d5ed327dc7..011a424b6c2 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue @@ -2,7 +2,7 @@ import { createAlert } from '~/alert'; import { __ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { reportMessageToSentry } from '~/ci/utils'; +import { reportToSentry } from '~/ci/utils'; import { mapEnvironmentNames } from '../utils'; import { ADD_MUTATION_ACTION, @@ -140,7 +140,7 @@ export default { this.loadingCounter += 1; } else { createAlert({ message: this.$options.tooManyCallsError }); - reportMessageToSentry(this.componentName, this.$options.tooManyCallsError, {}); + reportToSentry(this.componentName, new Error(this.$options.tooManyCallsError)); } } }, @@ -285,7 +285,6 @@ export default { :are-scoped-variables-available="areScopedVariablesAvailable" :entity="entity" :environments="environments" - :has-env-scope-query="hasEnvScopeQuery" :hide-environment-scope="hideEnvironmentScope" :is-loading="isLoading" :max-variable-limit="maxVariableLimit" diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql index a28ca4eebc9..f243a1cb30b 100644 --- a/app/assets/javascripts/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql @@ -1,5 +1,4 @@ fragment BaseCiVariable on CiVariable { - __typename id key value diff --git a/app/assets/javascripts/ci/ci_variable_list/utils.js b/app/assets/javascripts/ci/ci_variable_list/utils.js index 1faa97a5f73..a7e020206ea 100644 --- a/app/assets/javascripts/ci/ci_variable_list/utils.js +++ b/app/assets/javascripts/ci/ci_variable_list/utils.js @@ -1,29 +1,6 @@ -import { uniq } from 'lodash'; import { allEnvironments } from './constants'; /** - * This function takes a list of variable, environments and - * new environments added through the scope dropdown - * and create a new Array that concatenate the environment list - * with the environment scopes find in the variable list. This is - * useful for variable settings so that we can render a list of all - * environment scopes available based on the list of envs, the ones the user - * added explictly and what is found under each variable. - * @param {Array} variables - * @param {Array} environments - * @returns {Array} - Array of environments - */ - -export const createJoinedEnvironments = ( - variables = [], - environments = [], - newEnvironments = [], -) => { - const scopesFromVariables = variables.map((variable) => variable.environmentScope); - return uniq([...environments, ...newEnvironments, ...scopesFromVariables]).sort(); -}; - -/** * This function job is to convert the * wildcard to text when applicable * in the UI. It uses a constants to compare the incoming value to that * of the * and then apply the corresponding label if applicable. If there diff --git a/app/assets/javascripts/ci/common/pipelines_table.vue b/app/assets/javascripts/ci/common/pipelines_table.vue index 807128d2341..13b5120654a 100644 --- a/app/assets/javascripts/ci/common/pipelines_table.vue +++ b/app/assets/javascripts/ci/common/pipelines_table.vue @@ -3,39 +3,52 @@ import { GlTableLite, GlTooltipDirective } from '@gitlab/ui'; import { cleanLeadingSeparator } from '~/lib/utils/url_utility'; import { s__, __ } from '~/locale'; import Tracking from '~/tracking'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { TRACKING_CATEGORIES } from '~/ci/constants'; +import { PIPELINE_ID_KEY, PIPELINE_IID_KEY, TRACKING_CATEGORIES } from '~/ci/constants'; import { keepLatestDownstreamPipelines } from '~/ci/pipeline_details/utils/parsing_utils'; import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; import PipelineFailedJobsWidget from '~/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget.vue'; -import eventHub from '~/ci/event_hub'; import PipelineOperations from '../pipelines_page/components/pipeline_operations.vue'; -import PipelineStopModal from '../pipelines_page/components/pipeline_stop_modal.vue'; import PipelineTriggerer from '../pipelines_page/components/pipeline_triggerer.vue'; import PipelineUrl from '../pipelines_page/components/pipeline_url.vue'; -import PipelinesStatusBadge from '../pipelines_page/components/pipelines_status_badge.vue'; +import PipelineStatusBadge from '../pipelines_page/components/pipeline_status_badge.vue'; const HIDE_TD_ON_MOBILE = 'gl-display-none! gl-lg-display-table-cell!'; const DEFAULT_TH_CLASSES = 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!'; +/** + * Pipelines Table + * + * Presentational component of a table of pipelines. This component does not + * fetch the list of pipelines and instead expects it as a prop. + * GraphQL actions for pipelines, such as retrying, canceling, etc. + * are handled within this component. + * + * Use this `legacy_pipelines_table_wrapper` if you need a fully functional REST component. + * + * IMPORTANT: When using this component, make sure to handle the following events: + * 1- @refresh-pipeline-table + * 2- @cancel-pipeline + * 3- @retry-pipeline + * + */ + export default { components: { GlTableLite, LegacyPipelineMiniGraph, PipelineFailedJobsWidget, PipelineOperations, - PipelinesStatusBadge, - PipelineStopModal, + PipelineStatusBadge, PipelineTriggerer, PipelineUrl, }, directives: { GlTooltip: GlTooltipDirective, }, - mixins: [Tracking.mixin(), glFeatureFlagMixin()], + mixins: [Tracking.mixin()], inject: { - withFailedJobsDetails: { + useFailedJobsWidget: { default: false, }, }, @@ -44,37 +57,21 @@ export default { type: Array, required: true, }, - pipelineScheduleUrl: { - type: String, - required: false, - default: '', - }, updateGraphDropdown: { type: Boolean, required: false, default: false, }, - viewType: { + pipelineIdType: { type: String, - required: true, - }, - pipelineKeyOption: { - type: Object, - required: true, + required: false, + default: PIPELINE_ID_KEY, + validator(value) { + return value === PIPELINE_IID_KEY || value === PIPELINE_ID_KEY; + }, }, }, - data() { - return { - pipelineId: 0, - pipeline: {}, - endpoint: '', - cancelingPipeline: null, - }; - }, computed: { - showFailedJobsWidget() { - return this.glFeatures.ciJobFailuresInMr; - }, tableFields() { return [ { @@ -119,10 +116,10 @@ export default { ]; }, tdClasses() { - return this.withFailedJobsDetails ? 'gl-pb-0! gl-border-none!' : 'pl-p-5!'; + return this.useFailedJobsWidget ? 'gl-pb-0! gl-border-none!' : 'pl-p-5!'; }, pipelinesWithDetails() { - if (this.withFailedJobsDetails) { + if (this.useFailedJobsWidget) { return this.pipelines.map((p) => { return { ...p, _showDetails: true }; }); @@ -131,17 +128,6 @@ export default { return this.pipelines; }, }, - watch: { - pipelines() { - this.cancelingPipeline = null; - }, - }, - created() { - eventHub.$on('openConfirmationModal', this.setModalData); - }, - beforeDestroy() { - eventHub.$off('openConfirmationModal', this.setModalData); - }, methods: { getDownstreamPipelines(pipeline) { const downstream = pipeline.triggered; @@ -151,16 +137,19 @@ export default { return cleanLeadingSeparator(item.project.full_path); }, failedJobsCount(pipeline) { - return pipeline?.failed_builds?.length || 0; + // Remove `pipeline?.failed_builds?.length` when we remove `ci_fix_performance_pipelines_json_endpoint`. + return pipeline?.failed_builds_count || pipeline?.failed_builds?.length || 0; }, - setModalData(data) { - this.pipelineId = data.pipeline.id; - this.pipeline = data.pipeline; - this.endpoint = data.endpoint; + onRefreshPipelinesTable() { + this.$emit('refresh-pipelines-table'); }, - onSubmit() { - eventHub.$emit('postAction', this.endpoint); - this.cancelingPipeline = this.pipelineId; + onRetryPipeline(pipeline) { + // This emit is only used by the `legacy_pipelines_table_wrapper`. + this.$emit('retry-pipeline', pipeline); + }, + onCancelPipeline(pipeline) { + // This emit is only used by the `legacy_pipelines_table_wrapper`. + this.$emit('cancel-pipeline', pipeline); }, trackPipelineMiniGraph() { this.track('click_minigraph', { label: TRACKING_CATEGORIES.table }); @@ -168,7 +157,6 @@ export default { }, TBODY_TR_ATTR: { 'data-testid': 'pipeline-table-row', - 'data-qa-selector': 'pipeline_row_container', }, }; </script> @@ -191,14 +179,13 @@ export default { </template> <template #cell(status)="{ item }"> - <pipelines-status-badge :pipeline="item" :view-type="viewType" /> + <pipeline-status-badge :pipeline="item" /> </template> <template #cell(pipeline)="{ item }"> <pipeline-url :pipeline="item" - :pipeline-schedule-url="pipelineScheduleUrl" - :pipeline-key="pipelineKeyOption.value" + :pipeline-id-type="pipelineIdType" ref-color="gl-text-black-normal" /> </template> @@ -219,12 +206,17 @@ export default { </template> <template #cell(actions)="{ item }"> - <pipeline-operations :pipeline="item" :canceling-pipeline="cancelingPipeline" /> + <pipeline-operations + :pipeline="item" + @cancel-pipeline="onCancelPipeline" + @refresh-pipelines-table="onRefreshPipelinesTable" + @retry-pipeline="onRetryPipeline" + /> </template> <template #row-details="{ item }"> <pipeline-failed-jobs-widget - v-if="showFailedJobsWidget" + v-if="useFailedJobsWidget" :failed-jobs-count="failedJobsCount(item)" :is-pipeline-active="item.active" :pipeline-iid="item.iid" @@ -234,7 +226,5 @@ export default { /> </template> </gl-table-lite> - - <pipeline-stop-modal :pipeline="pipeline" @submit="onSubmit" /> </div> </template> diff --git a/app/assets/javascripts/ci/common/private/job_action_component.vue b/app/assets/javascripts/ci/common/private/job_action_component.vue index f649750ce8a..b0fa724d450 100644 --- a/app/assets/javascripts/ci/common/private/job_action_component.vue +++ b/app/assets/javascripts/ci/common/private/job_action_component.vue @@ -120,7 +120,7 @@ export default { :class="cssClass" :disabled="isDisabled" class="js-ci-action gl-ci-action-icon-container ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center" - data-testid="ci-action-component" + data-testid="ci-action-button" @click.stop="onClickAction" > <div diff --git a/app/assets/javascripts/ci/constants.js b/app/assets/javascripts/ci/constants.js index 93c2504dd5d..5b60528f521 100644 --- a/app/assets/javascripts/ci/constants.js +++ b/app/assets/javascripts/ci/constants.js @@ -24,19 +24,8 @@ export const SUCCESS_STATUS = 'SUCCESS'; export const PASSED_STATUS = 'passed'; export const MANUAL_STATUS = 'manual'; -// Constants for the ID and IID selection dropdown -export const PipelineKeyOptions = [ - { - text: __('Show Pipeline ID'), - label: __('Pipeline ID'), - value: 'id', - }, - { - text: __('Show Pipeline IID'), - label: __('Pipeline IID'), - value: 'iid', - }, -]; +export const PIPELINE_ID_KEY = 'id'; +export const PIPELINE_IID_KEY = 'iid'; export const RAW_TEXT_WARNING = s__( 'Pipeline|Raw text search is not currently supported. Please use the available search tokens.', diff --git a/app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue b/app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue index f02d59af1d9..0b079ccb64f 100644 --- a/app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue +++ b/app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue @@ -3,7 +3,7 @@ import { produce } from 'immer'; import { s__ } from '~/locale'; import { createAlert } from '~/alert'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { reportMessageToSentry } from '~/ci/utils'; +import { reportToSentry } from '~/ci/utils'; import CiVariableTable from '~/ci/ci_variable_list/components/ci_variable_table.vue'; import getInheritedCiVariables from '../graphql/queries/inherited_ci_variables.query.graphql'; @@ -51,7 +51,7 @@ export default { this.loadingCounter += 1; } else { createAlert({ message: this.$options.i18n.tooManyCallsError }); - reportMessageToSentry(this.$options.name, this.$options.i18n.tooManyCallsError, {}); + reportToSentry(this.$options.name, new Error(this.$options.i18n.tooManyCallsError)); } }, error() { diff --git a/app/assets/javascripts/ci/inherited_ci_variables/graphql/queries/inherited_ci_variables.query.graphql b/app/assets/javascripts/ci/inherited_ci_variables/graphql/queries/inherited_ci_variables.query.graphql index b25768632e1..9fac461a47d 100644 --- a/app/assets/javascripts/ci/inherited_ci_variables/graphql/queries/inherited_ci_variables.query.graphql +++ b/app/assets/javascripts/ci/inherited_ci_variables/graphql/queries/inherited_ci_variables.query.graphql @@ -8,7 +8,6 @@ query getInheritedCiVariables($after: String, $first: Int, $fullPath: ID!) { ...PageInfo } nodes { - __typename id key variableType diff --git a/app/assets/javascripts/ci/job_details/components/job_header.vue b/app/assets/javascripts/ci/job_details/components/job_header.vue index 13f3eebd447..00d15f87064 100644 --- a/app/assets/javascripts/ci/job_details/components/job_header.vue +++ b/app/assets/javascripts/ci/job_details/components/job_header.vue @@ -89,18 +89,36 @@ export default { <template> <header - class="page-content-header gl-md-display-flex gl-min-h-7" + class="page-content-header gl-md-display-flex gl-flex-wrap gl-min-h-7 gl-pb-2! gl-w-full" data-testid="job-header-content" > - <section class="header-main-content gl-mr-3"> - <ci-badge-link class="gl-mr-3" :status="status" /> + <div + v-if="name" + class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full" + > + <h1 class="gl-font-size-h-display gl-my-0 gl-display-inline-block" data-testid="job-name"> + {{ name }} + </h1> - <strong data-testid="job-name">{{ name }}</strong> + <div class="gl-display-flex gl-align-self-start gl-mt-n2"> + <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right"> + <gl-button + :aria-label="__('Toggle sidebar')" + category="secondary" + class="gl-lg-display-none gl-ml-2" + icon="chevron-double-lg-left" + @click="onClickSidebarButton" + /> + </div> + </div> + </div> + <section class="header-main-content gl-display-flex gl-align-items-center gl-mr-3"> + <ci-badge-link class="gl-mr-3" :status="status" /> - <template v-if="shouldRenderTriggeredLabel">{{ __('started') }}</template> - <template v-else>{{ __('created') }}</template> + <template v-if="shouldRenderTriggeredLabel">{{ __('Started') }}</template> + <template v-else>{{ __('Created') }}</template> - <timeago-tooltip :time="time" /> + <timeago-tooltip :time="time" class="gl-mx-2" /> {{ __('by') }} @@ -133,16 +151,5 @@ export default { </gl-avatar-link> </template> </section> - - <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots --> - <section v-if="$slots.default" data-testid="job-header-action-buttons" class="gl-display-flex"> - <slot></slot> - </section> - <gl-button - class="gl-md-display-none gl-ml-auto gl-align-self-start js-sidebar-build-toggle" - icon="chevron-double-lg-left" - :aria-label="__('Toggle sidebar')" - @click="onClickSidebarButton" - /> </header> </template> diff --git a/app/assets/javascripts/ci/job_details/components/job_log_controllers.vue b/app/assets/javascripts/ci/job_details/components/job_log_controllers.vue index 419efcba46d..4a30878bec5 100644 --- a/app/assets/javascripts/ci/job_details/components/job_log_controllers.vue +++ b/app/assets/javascripts/ci/job_details/components/job_log_controllers.vue @@ -146,7 +146,7 @@ export default { // BE returns zero based index, we need to add one to match the line numbers in the DOM const firstSearchResult = `#L${this.searchResults[0].lineNumber + 1}`; - const logLine = document.querySelector(`.log-line ${firstSearchResult}`); + const logLine = document.querySelector(`.js-log-line ${firstSearchResult}`); if (logLine) { setTimeout(() => scrollToElement(logLine)); diff --git a/app/assets/javascripts/ci/job_details/components/log/line.vue b/app/assets/javascripts/ci/job_details/components/log/line.vue index fa4a12b3dd3..416f75372f9 100644 --- a/app/assets/javascripts/ci/job_details/components/log/line.vue +++ b/app/assets/javascripts/ci/job_details/components/log/line.vue @@ -56,7 +56,7 @@ export default { if (window.location.hash) { const hash = getLocationHash(); - const lineToMatch = `L${line.lineNumber + 1}`; + const lineToMatch = `L${line.lineNumber}`; if (hash === lineToMatch) { applyHashHighlight = true; @@ -66,7 +66,11 @@ export default { return h( 'div', { - class: ['js-line', 'log-line', { 'gl-bg-gray-700': isHighlighted || applyHashHighlight }], + class: [ + 'js-log-line', + 'log-line', + { 'gl-bg-gray-700': isHighlighted || applyHashHighlight }, + ], }, [ h(LineNumber, { diff --git a/app/assets/javascripts/ci/job_details/components/log/line_header.vue b/app/assets/javascripts/ci/job_details/components/log/line_header.vue index e647ab4ac0b..658a94e6af4 100644 --- a/app/assets/javascripts/ci/job_details/components/log/line_header.vue +++ b/app/assets/javascripts/ci/job_details/components/log/line_header.vue @@ -46,7 +46,7 @@ export default { }, mounted() { const hash = getLocationHash(); - const lineToMatch = `L${this.line.lineNumber + 1}`; + const lineToMatch = `L${this.line.lineNumber}`; if (hash === lineToMatch) { this.applyHashHighlight = true; @@ -62,7 +62,7 @@ export default { <template> <div - class="log-line collapsible-line d-flex justify-content-between ws-normal gl-align-items-flex-start gl-relative" + class="js-log-line log-line collapsible-line d-flex justify-content-between ws-normal gl-align-items-flex-start gl-relative" :class="{ 'gl-bg-gray-700': isHighlighted || applyHashHighlight }" role="button" @click="handleOnClick" diff --git a/app/assets/javascripts/ci/job_details/components/log/line_number.vue b/app/assets/javascripts/ci/job_details/components/log/line_number.vue index 7ca9154d2fe..30b4c80f3fa 100644 --- a/app/assets/javascripts/ci/job_details/components/log/line_number.vue +++ b/app/assets/javascripts/ci/job_details/components/log/line_number.vue @@ -14,8 +14,7 @@ export default { render(h, { props }) { const { lineNumber, path } = props; - const parsedLineNumber = lineNumber + 1; - const lineId = `L${parsedLineNumber}`; + const lineId = `L${lineNumber}`; const lineHref = `${path}#${lineId}`; return h( @@ -27,7 +26,7 @@ export default { href: lineHref, }, }, - parsedLineNumber, + lineNumber, ); }, }; diff --git a/app/assets/javascripts/ci/job_details/components/manual_variables_form.vue b/app/assets/javascripts/ci/job_details/components/manual_variables_form.vue index 1232ffffb57..7f419a249cf 100644 --- a/app/assets/javascripts/ci/job_details/components/manual_variables_form.vue +++ b/app/assets/javascripts/ci/job_details/components/manual_variables_form.vue @@ -18,7 +18,7 @@ import { JOB_GRAPHQL_ERRORS } from '~/ci/constants'; import { helpPagePath } from '~/helpers/help_page_helper'; import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated import { s__ } from '~/locale'; -import { reportMessageToSentry } from '~/ci/utils'; +import { reportToSentry } from '~/ci/utils'; import GetJob from '../graphql/queries/get_job.query.graphql'; import playJobWithVariablesMutation from '../graphql/mutations/job_play_with_variables.mutation.graphql'; import retryJobWithVariablesMutation from '../graphql/mutations/job_retry_with_variables.mutation.graphql'; @@ -57,7 +57,7 @@ export default { }, error(error) { createAlert({ message: JOB_GRAPHQL_ERRORS.jobQueryErrorText }); - reportMessageToSentry(this.$options.name, error, {}); + reportToSentry(this.$options.name, error); }, }, }, @@ -141,7 +141,7 @@ export default { } } catch (error) { createAlert({ message: JOB_GRAPHQL_ERRORS.jobMutationErrorText }); - reportMessageToSentry(this.$options.name, error, {}); + reportToSentry(this.$options.name, error); } }, async retryJob() { @@ -157,7 +157,7 @@ export default { } } catch (error) { createAlert({ message: JOB_GRAPHQL_ERRORS.jobMutationErrorText }); - reportMessageToSentry(this.$options.name, error, {}); + reportToSentry(this.$options.name, error); } }, addEmptyVariable() { diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/artifacts_block.vue b/app/assets/javascripts/ci/job_details/components/sidebar/artifacts_block.vue index 4c81a9bd033..f6d39e8e4ac 100644 --- a/app/assets/javascripts/ci/job_details/components/sidebar/artifacts_block.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/artifacts_block.vue @@ -78,7 +78,7 @@ export default { <span v-if="willExpire" data-testid="artifacts-unlocked-message-content"> {{ $options.i18n.willExpireText }} </span> - <timeago-tooltip v-if="artifact.expire_at" :time="artifact.expire_at" /> + <timeago-tooltip v-if="artifact.expireAt" :time="artifact.expireAt" /> <gl-link :href="helpUrl" target="_blank" @@ -95,23 +95,23 @@ export default { </p> <gl-button-group class="gl-display-flex gl-mt-3"> <gl-button - v-if="artifact.keep_path" - :href="artifact.keep_path" + v-if="artifact.keepPath" + :href="artifact.keepPath" data-method="post" data-testid="keep-artifacts" >{{ $options.i18n.keepText }}</gl-button > <gl-button - v-if="artifact.download_path" - :href="artifact.download_path" + v-if="artifact.downloadPath" + :href="artifact.downloadPath" rel="nofollow" data-testid="download-artifacts" download >{{ $options.i18n.downloadText }}</gl-button > <gl-button - v-if="artifact.browse_path" - :href="artifact.browse_path" + v-if="artifact.browsePath" + :href="artifact.browsePath" data-testid="browse-artifacts-button" >{{ $options.i18n.browseText }}</gl-button > diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/commit_block.vue b/app/assets/javascripts/ci/job_details/components/sidebar/commit_block.vue index 95616a4c706..5e826efbefb 100644 --- a/app/assets/javascripts/ci/job_details/components/sidebar/commit_block.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/commit_block.vue @@ -25,11 +25,7 @@ export default { <p class="gl-display-flex gl-flex-wrap gl-align-items-baseline gl-gap-2 gl-mb-0"> <span class="gl-display-flex gl-font-weight-bold">{{ __('Commit') }}</span> - <gl-link - :href="commit.commit_path" - class="gl-text-blue-500! gl-font-monospace" - data-testid="commit-sha" - > + <gl-link :href="commit.commit_path" class="commit-sha-container" data-testid="commit-sha"> {{ commit.short_id }} </gl-link> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue index 7f2f4fc0331..231f45d7ae6 100644 --- a/app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue @@ -4,6 +4,8 @@ import { isEmpty } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; import { forwardDeploymentFailureModalId } from '~/ci/constants'; import { filterAnnotations } from '~/ci/job_details/utils'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; import ArtifactsBlock from './artifacts_block.vue'; import CommitBlock from './commit_block.vue'; import ExternalLinksBlock from './external_links_block.vue'; @@ -15,6 +17,9 @@ import StagesDropdown from './stages_dropdown.vue'; import TriggerBlock from './trigger_block.vue'; export default { + i18n: { + toggleSidebar: __('Toggle Sidebar'), + }, name: 'JobSidebar', forwardDeploymentFailureModalId, components: { @@ -42,6 +47,9 @@ export default { // the artifact object will always have a locked property return Object.keys(this.job.artifact).length > 1; }, + artifact() { + return convertObjectPropsToCamelCase(this.job.artifact, { deep: true }); + }, hasExternalLinks() { return this.externalLinks.length > 0; }, @@ -79,36 +87,44 @@ export default { <template> <aside class="right-sidebar build-sidebar" data-offset-top="101" data-spy="affix"> <div class="sidebar-container"> - <div class="blocks-container gl-p-4"> + <div class="blocks-container gl-p-4 gl-pt-0"> <sidebar-header - class="block gl-pb-4! gl-mb-2" + class="gl-py-4 gl-border-b gl-border-gray-50" :rest-job="job" :job-id="job.id" @updateVariables="$emit('updateVariables')" /> - <job-sidebar-details-container class="block gl-mb-2" /> + <job-sidebar-details-container class="gl-py-4 gl-border-b gl-border-gray-50" /> <artifacts-block v-if="hasArtifact" - class="block gl-mb-2" - :artifact="job.artifact" + class="gl-py-4 gl-border-b gl-border-gray-50" + :artifact="artifact" :help-url="artifactHelpUrl" /> <external-links-block v-if="hasExternalLinks" - class="block gl-mb-2" + class="gl-py-4 gl-border-b gl-border-gray-50" :external-links="externalLinks" /> - <trigger-block v-if="hasTriggers" class="block gl-mb-2" :trigger="job.trigger" /> + <trigger-block + v-if="hasTriggers" + class="gl-py-4 gl-border-b gl-border-gray-50" + :trigger="job.trigger" + /> - <commit-block class="block gl-mb-2" :commit="commit" :merge-request="job.merge_request" /> + <commit-block + class="gl-py-4 gl-border-b gl-border-gray-50" + :commit="commit" + :merge-request="job.merge_request" + /> <stages-dropdown v-if="job.pipeline" - class="block gl-mb-2" + class="gl-py-4 gl-border-b gl-border-gray-50" :pipeline="job.pipeline" :selected-stage="selectedStage" :stages="stages" diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_detail_row.vue b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_detail_row.vue index 5b1bf354fd4..d7726b952de 100644 --- a/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_detail_row.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_detail_row.vue @@ -39,8 +39,8 @@ export default { }; </script> <template> - <p class="build-sidebar-item gl-mb-2"> - <b v-if="hasTitle" class="gl-display-flex">{{ title }}:</b> + <p class="build-sidebar-item gl-line-height-normal gl-display-flex gl-mb-3"> + <b v-if="hasTitle" class="gl-mr-3">{{ title }}:</b> <gl-link v-if="path" :href="path" diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_header.vue b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_header.vue index 77e3ecb9b3c..f757a3bcf00 100644 --- a/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_header.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_header.vue @@ -6,7 +6,6 @@ import { createAlert } from '~/alert'; import { TYPENAME_COMMIT_STATUS } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { __, s__ } from '~/locale'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { JOB_GRAPHQL_ERRORS, forwardDeploymentFailureModalId, PASSED_STATUS } from '~/ci/constants'; import GetJob from '../../graphql/queries/get_job.query.graphql'; import JobSidebarRetryButton from './job_sidebar_retry_button.vue'; @@ -20,7 +19,6 @@ export default { eraseLogConfirmText: s__('Job|Are you sure you want to erase this job log and artifacts?'), newIssue: __('New issue'), retryJobLabel: s__('Job|Retry'), - toggleSidebar: __('Toggle Sidebar'), runAgainJobButtonLabel: s__('Job|Run again'), }, forwardDeploymentFailureModalId, @@ -30,7 +28,6 @@ export default { components: { GlButton, JobSidebarRetryButton, - TooltipOnTruncate, }, inject: ['projectPath'], apollo: { @@ -85,6 +82,15 @@ export default { retryButtonCategory() { return this.restJob.status && this.restJob.recoverable ? 'primary' : 'secondary'; }, + jobHasPath() { + return Boolean( + this.restJob.erase_path || + this.restJob.new_issue_path || + this.restJob.terminal_path || + this.restJob.retry_path || + this.restJob.cancel_path, + ); + }, }, methods: { ...mapActions(['toggleSidebar']), @@ -93,73 +99,74 @@ export default { </script> <template> - <div> - <tooltip-on-truncate :title="job.name" truncate-target="child" - ><h4 class="gl-mt-0 gl-mb-3 gl-text-truncate" data-testid="job-name">{{ job.name }}</h4> - </tooltip-on-truncate> - <div class="gl-display-flex gl-gap-3"> - <gl-button - v-if="restJob.erase_path" - v-gl-tooltip.bottom - :title="$options.i18n.eraseLogButtonLabel" - :aria-label="$options.i18n.eraseLogButtonLabel" - :href="restJob.erase_path" - :data-confirm="$options.i18n.eraseLogConfirmText" - data-testid="job-log-erase-link" - data-confirm-btn-variant="danger" - data-method="post" - icon="remove" - /> - <gl-button - v-if="restJob.new_issue_path" - v-gl-tooltip.bottom - :href="restJob.new_issue_path" - :title="$options.i18n.newIssue" - :aria-label="$options.i18n.newIssue" - category="secondary" - variant="confirm" - data-testid="job-new-issue" - icon="issue-new" - /> - <gl-button - v-if="restJob.terminal_path" - v-gl-tooltip.bottom - :href="restJob.terminal_path" - :title="$options.i18n.debug" - :aria-label="$options.i18n.debug" - target="_blank" - icon="external-link" - data-testid="terminal-link" - /> - <job-sidebar-retry-button - v-if="canShowJobRetryButton" - v-gl-tooltip.bottom - :title="buttonTitle" - :aria-label="buttonTitle" - :is-manual-job="isManualJob" - :category="retryButtonCategory" - :href="restJob.retry_path" - :modal-id="$options.forwardDeploymentFailureModalId" - variant="confirm" - data-testid="retry-button" - @updateVariablesClicked="$emit('updateVariables')" - /> - <gl-button - v-if="restJob.cancel_path" - v-gl-tooltip.bottom - :title="$options.i18n.cancelJobButtonLabel" - :aria-label="$options.i18n.cancelJobButtonLabel" - :href="restJob.cancel_path" - variant="danger" - icon="cancel" - data-method="post" - data-testid="cancel-button" - rel="nofollow" - /> + <div class="gl-py-3!"> + <div class="gl-display-flex gl-justify-content-space-between gl-gap-3"> + <div class="gl-display-flex gl-gap-3"> + <template v-if="jobHasPath"> + <gl-button + v-if="restJob.erase_path" + v-gl-tooltip.bottom + :title="$options.i18n.eraseLogButtonLabel" + :aria-label="$options.i18n.eraseLogButtonLabel" + :href="restJob.erase_path" + :data-confirm="$options.i18n.eraseLogConfirmText" + data-testid="job-log-erase-link" + data-confirm-btn-variant="danger" + data-method="post" + icon="remove" + /> + <gl-button + v-if="restJob.new_issue_path" + v-gl-tooltip.bottom + :href="restJob.new_issue_path" + :title="$options.i18n.newIssue" + :aria-label="$options.i18n.newIssue" + category="secondary" + variant="confirm" + data-testid="job-new-issue" + icon="issue-new" + /> + <gl-button + v-if="restJob.terminal_path" + v-gl-tooltip.bottom + :href="restJob.terminal_path" + :title="$options.i18n.debug" + :aria-label="$options.i18n.debug" + target="_blank" + icon="external-link" + data-testid="terminal-link" + /> + <job-sidebar-retry-button + v-if="canShowJobRetryButton" + v-gl-tooltip.bottom + :title="buttonTitle" + :aria-label="buttonTitle" + :is-manual-job="isManualJob" + :category="retryButtonCategory" + :href="restJob.retry_path" + :modal-id="$options.forwardDeploymentFailureModalId" + variant="confirm" + data-testid="retry-button" + @updateVariablesClicked="$emit('updateVariables')" + /> + <gl-button + v-if="restJob.cancel_path" + v-gl-tooltip.bottom + :title="$options.i18n.cancelJobButtonLabel" + :aria-label="$options.i18n.cancelJobButtonLabel" + :href="restJob.cancel_path" + variant="danger" + icon="cancel" + data-method="post" + data-testid="cancel-button" + rel="nofollow" + /> + </template> + </div> <gl-button :aria-label="$options.i18n.toggleSidebar" category="secondary" - class="gl-md-display-none gl-ml-2" + class="gl-lg-display-none" icon="chevron-double-lg-right" @click="toggleSidebar" /> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_job_details_container.vue b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_job_details_container.vue index ebef3ecaa3f..f04987a87b5 100644 --- a/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_job_details_container.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_job_details_container.vue @@ -44,14 +44,10 @@ export default { this.job.finished_at || this.job.erased_at || this.job.queued_duration || - this.job.id || this.job.runner || this.job.coverage, ); }, - jobId() { - return this.job?.id ? `#${this.job.id}` : ''; - }, runnerId() { const { id, short_sha: token, description } = this.job.runner; @@ -87,7 +83,6 @@ export default { RUNNER: __('Runner'), TAGS: __('Tags'), TIMEOUT: __('Timeout'), - ID: __('Job ID'), }, TIMEOUT_HELP_URL: helpPagePath('/ci/pipelines/settings.md', { anchor: 'set-a-limit-for-how-long-jobs-can-run', @@ -113,7 +108,6 @@ export default { data-testid="job-timeout" :title="$options.i18n.TIMEOUT" /> - <detail-row v-if="job.id" :value="jobId" :title="$options.i18n.ID" /> <detail-row v-if="job.runner" :value="runnerId" diff --git a/app/assets/javascripts/ci/job_details/components/stuck_block.vue b/app/assets/javascripts/ci/job_details/components/stuck_block.vue index 8c73f09daea..b8ff0b032cc 100644 --- a/app/assets/javascripts/ci/job_details/components/stuck_block.vue +++ b/app/assets/javascripts/ci/job_details/components/stuck_block.vue @@ -78,7 +78,7 @@ export default { </template> </gl-sprintf> <template v-if="stuckData.showTags"> - <gl-badge v-for="tag in tags" :key="tag" variant="info"> + <gl-badge v-for="tag in tags" :key="tag" size="sm" variant="info"> {{ tag }} </gl-badge> </template> diff --git a/app/assets/javascripts/ci/job_details/graphql/fragments/ci_job.fragment.graphql b/app/assets/javascripts/ci/job_details/graphql/fragments/ci_job.fragment.graphql index 7fb887b2dd4..3a27a9a62a3 100644 --- a/app/assets/javascripts/ci/job_details/graphql/fragments/ci_job.fragment.graphql +++ b/app/assets/javascripts/ci/job_details/graphql/fragments/ci_job.fragment.graphql @@ -7,5 +7,4 @@ fragment BaseCiJob on CiJob { ...ManualCiVariable } } - __typename } diff --git a/app/assets/javascripts/ci/job_details/graphql/fragments/ci_variable.fragment.graphql b/app/assets/javascripts/ci/job_details/graphql/fragments/ci_variable.fragment.graphql index 0479df7bc4c..e560a2f29b6 100644 --- a/app/assets/javascripts/ci/job_details/graphql/fragments/ci_variable.fragment.graphql +++ b/app/assets/javascripts/ci/job_details/graphql/fragments/ci_variable.fragment.graphql @@ -1,5 +1,4 @@ fragment ManualCiVariable on CiVariable { - __typename id key value diff --git a/app/assets/javascripts/ci/job_details/graphql/mutations/job_retry_with_variables.mutation.graphql b/app/assets/javascripts/ci/job_details/graphql/mutations/job_retry_with_variables.mutation.graphql index cd66a30ce63..b7c93c2830a 100644 --- a/app/assets/javascripts/ci/job_details/graphql/mutations/job_retry_with_variables.mutation.graphql +++ b/app/assets/javascripts/ci/job_details/graphql/mutations/job_retry_with_variables.mutation.graphql @@ -1,6 +1,6 @@ #import "~/ci/job_details/graphql/fragments/ci_job.fragment.graphql" -mutation retryJobWithVariables($id: CiBuildID!, $variables: [CiVariableInput!]) { +mutation retryJobWithVariables($id: CiProcessableID!, $variables: [CiVariableInput!]) { jobRetry(input: { id: $id, variables: $variables }) { job { ...BaseCiJob diff --git a/app/assets/javascripts/ci/job_details/index.js b/app/assets/javascripts/ci/job_details/index.js index 5a1ecf2fff3..20235015ce6 100644 --- a/app/assets/javascripts/ci/job_details/index.js +++ b/app/assets/javascripts/ci/job_details/index.js @@ -13,11 +13,11 @@ const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), }); -const initializeJobPage = (element) => { - const store = createStore(); - - // Let's start initializing the store (i.e. fetching data) right away - store.dispatch('init', element.dataset); +export const initJobDetails = () => { + const el = document.getElementById('js-job-page'); + if (!el) { + return null; + } const { artifactHelpUrl, @@ -26,27 +26,27 @@ const initializeJobPage = (element) => { subscriptionsMoreMinutesUrl, endpoint, pagePath, - logState, buildStatus, projectPath, retryOutdatedJobDocsUrl, aiRootCauseAnalysisAvailable, - } = element.dataset; + } = el.dataset; + + // init store to start fetching log + const store = createStore(); + store.dispatch('init', { endpoint, pagePath }); return new Vue({ - el: element, + el, apolloProvider, store, - components: { - JobApp, - }, provide: { projectPath, retryOutdatedJobDocsUrl, aiRootCauseAnalysisAvailable: parseBoolean(aiRootCauseAnalysisAvailable), }, - render(createElement) { - return createElement('job-app', { + render(h) { + return h(JobApp, { props: { artifactHelpUrl, deploymentHelpUrl, @@ -54,7 +54,6 @@ const initializeJobPage = (element) => { subscriptionsMoreMinutesUrl, endpoint, pagePath, - logState, buildStatus, projectPath, }, @@ -62,8 +61,3 @@ const initializeJobPage = (element) => { }, }); }; - -export default () => { - const jobElement = document.getElementById('js-job-page'); - initializeJobPage(jobElement); -}; diff --git a/app/assets/javascripts/ci/job_details/job_app.vue b/app/assets/javascripts/ci/job_details/job_app.vue index 5137ebfeaa8..119f8259be7 100644 --- a/app/assets/javascripts/ci/job_details/job_app.vue +++ b/app/assets/javascripts/ci/job_details/job_app.vue @@ -130,7 +130,7 @@ export default { }, jobName() { - return sprintf(__('Job %{jobName}'), { jobName: this.job.name }); + return sprintf(__('%{jobName}'), { jobName: this.job.name }); }, }, watch: { @@ -195,7 +195,7 @@ export default { }, updateSidebar() { const breakpoint = bp.getBreakpointSize(); - if (breakpoint === 'xs' || breakpoint === 'sm') { + if (breakpoint === 'xs' || breakpoint === 'sm' || breakpoint === 'md') { this.hideSidebar(); } else if (!this.isSidebarOpen) { this.showSidebar(); @@ -224,7 +224,7 @@ export default { <div class="build-page" data-testid="job-content"> <!-- Header Section --> <header> - <div class="build-header top-area"> + <div class="build-header gl-display-flex"> <job-header :status="job.status" :time="headerTime" @@ -290,11 +290,7 @@ export default { {{ __('This job is archived. Only the complete pipeline can be retried.') }} </div> <!-- job log --> - <div - v-if="hasJobLog && !showUpdateVariablesState" - class="build-log-container gl-relative" - :class="{ 'gl-mt-3': !job.archived }" - > + <div v-if="hasJobLog && !showUpdateVariablesState" class="build-log-container gl-relative"> <log-top-bar :class="{ 'has-archived-block': job.archived, @@ -332,18 +328,17 @@ export default { <!-- EO empty state --> <!-- EO Body Section --> + + <sidebar + :class="{ + 'right-sidebar-expanded': isSidebarOpen, + 'right-sidebar-collapsed': !isSidebarOpen, + }" + :artifact-help-url="artifactHelpUrl" + data-testid="job-sidebar" + @updateVariables="onUpdateVariables()" + /> </div> </template> - - <sidebar - v-if="shouldRenderContent" - :class="{ - 'right-sidebar-expanded': isSidebarOpen, - 'right-sidebar-collapsed': !isSidebarOpen, - }" - :artifact-help-url="artifactHelpUrl" - data-testid="job-sidebar" - @updateVariables="onUpdateVariables()" - /> </div> </template> diff --git a/app/assets/javascripts/ci/job_details/store/actions.js b/app/assets/javascripts/ci/job_details/store/actions.js index 33d83689e61..fa23589f7d6 100644 --- a/app/assets/javascripts/ci/job_details/store/actions.js +++ b/app/assets/javascripts/ci/job_details/store/actions.js @@ -15,17 +15,15 @@ import { __ } from '~/locale'; import { reportToSentry } from '~/ci/utils'; import * as types from './mutation_types'; -export const init = ({ dispatch }, { endpoint, logState, pagePath }) => { - dispatch('setJobEndpoint', endpoint); +export const init = ({ dispatch }, { endpoint, pagePath }) => { dispatch('setJobLogOptions', { - logState, + endpoint, pagePath, }); return dispatch('fetchJob'); }; -export const setJobEndpoint = ({ commit }, endpoint) => commit(types.SET_JOB_ENDPOINT, endpoint); export const setJobLogOptions = ({ commit }, options) => commit(types.SET_JOB_LOG_OPTIONS, options); export const hideSidebar = ({ commit }) => commit(types.HIDE_SIDEBAR); diff --git a/app/assets/javascripts/ci/job_details/store/mutation_types.js b/app/assets/javascripts/ci/job_details/store/mutation_types.js index 4915a826b84..e125538317d 100644 --- a/app/assets/javascripts/ci/job_details/store/mutation_types.js +++ b/app/assets/javascripts/ci/job_details/store/mutation_types.js @@ -1,4 +1,3 @@ -export const SET_JOB_ENDPOINT = 'SET_JOB_ENDPOINT'; export const SET_JOB_LOG_OPTIONS = 'SET_JOB_LOG_OPTIONS'; export const HIDE_SIDEBAR = 'HIDE_SIDEBAR'; diff --git a/app/assets/javascripts/ci/job_details/store/mutations.js b/app/assets/javascripts/ci/job_details/store/mutations.js index b7d7006ee61..fe6506bf8a5 100644 --- a/app/assets/javascripts/ci/job_details/store/mutations.js +++ b/app/assets/javascripts/ci/job_details/store/mutations.js @@ -3,13 +3,9 @@ import * as types from './mutation_types'; import { logLinesParser, updateIncrementalJobLog } from './utils'; export default { - [types.SET_JOB_ENDPOINT](state, endpoint) { - state.jobEndpoint = endpoint; - }, - [types.SET_JOB_LOG_OPTIONS](state, options = {}) { state.jobLogEndpoint = options.pagePath; - state.jobLogState = options.logState; + state.jobEndpoint = options.endpoint; }, [types.HIDE_SIDEBAR](state) { diff --git a/app/assets/javascripts/ci/job_details/store/utils.js b/app/assets/javascripts/ci/job_details/store/utils.js index bc76901026d..b18a3fa162d 100644 --- a/app/assets/javascripts/ci/job_details/store/utils.js +++ b/app/assets/javascripts/ci/job_details/store/utils.js @@ -19,20 +19,17 @@ export const parseLine = (line = {}, lineNumber) => ({ * @param Number lineNumber */ export const parseHeaderLine = (line = {}, lineNumber, hash) => { + let isClosed = parseBoolean(line.section_options?.collapsed); + // if a hash is present in the URL then we ensure // all sections are visible so we can scroll to the hash // in the DOM if (hash) { - return { - isClosed: false, - isHeader: true, - line: parseLine(line, lineNumber), - lines: [], - }; + isClosed = false; } return { - isClosed: parseBoolean(line.section_options?.collapsed), + isClosed, isHeader: true, line: parseLine(line, lineNumber), lines: [], @@ -80,27 +77,28 @@ export const isCollapsibleSection = (acc = [], last = {}, section = {}) => section.section === last.line.section; /** - * Returns the lineNumber of the last line in - * a parsed log + * Returns the next line number in the parsed log * * @param Array acc * @returns Number */ -export const getIncrementalLineNumber = (acc) => { - let lineNumberValue; - const lastIndex = acc.length - 1; - const lastElement = acc[lastIndex]; +export const getNextLineNumber = (acc) => { + if (!acc?.length) { + return 1; + } + + const lastElement = acc[acc.length - 1]; const nestedLines = lastElement.lines; if (lastElement.isHeader && !nestedLines.length && lastElement.line) { - lineNumberValue = lastElement.line.lineNumber; - } else if (lastElement.isHeader && nestedLines.length) { - lineNumberValue = nestedLines[nestedLines.length - 1].lineNumber; - } else { - lineNumberValue = lastElement.lineNumber; + return lastElement.line.lineNumber + 1; } - return lineNumberValue === 0 ? 1 : lineNumberValue + 1; + if (lastElement.isHeader && nestedLines.length) { + return nestedLines[nestedLines.length - 1].lineNumber + 1; + } + + return lastElement.lineNumber + 1; }; /** @@ -118,32 +116,29 @@ export const getIncrementalLineNumber = (acc) => { * @param Array accumulator * @returns Array parsed log lines */ -export const logLinesParser = (lines = [], accumulator = [], hash = '') => - lines.reduce( - (acc, line, index) => { - const lineNumber = accumulator.length > 0 ? getIncrementalLineNumber(acc) : index; - - const last = acc[acc.length - 1]; - - // If the object is an header, we parse it into another structure - if (line.section_header) { - acc.push(parseHeaderLine(line, lineNumber, hash)); - } else if (isCollapsibleSection(acc, last, line)) { - // if the object belongs to a nested section, we append it to the new `lines` array of the - // previously formatted header - last.lines.push(parseLine(line, lineNumber)); - } else if (line.section_duration) { - // if the line has section_duration, we look for the correct header to add it - addDurationToHeader(acc, line); - } else { - // otherwise it's a regular line - acc.push(parseLine(line, lineNumber)); - } +export const logLinesParser = (lines = [], prevLogLines = [], hash = '') => + lines.reduce((acc, line) => { + const lineNumber = getNextLineNumber(acc); + + const last = acc[acc.length - 1]; + + // If the object is an header, we parse it into another structure + if (line.section_header) { + acc.push(parseHeaderLine(line, lineNumber, hash)); + } else if (isCollapsibleSection(acc, last, line)) { + // if the object belongs to a nested section, we append it to the new `lines` array of the + // previously formatted header + last.lines.push(parseLine(line, lineNumber)); + } else if (line.section_duration) { + // if the line has section_duration, we look for the correct header to add it + addDurationToHeader(acc, line); + } else { + // otherwise it's a regular line + acc.push(parseLine(line, lineNumber)); + } - return acc; - }, - [...accumulator], - ); + return acc; + }, prevLogLines); /** * Finds the repeated offset, removes the old one diff --git a/app/assets/javascripts/ci/jobs_page/components/job_cells/actions_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/actions_cell.vue index 609f2790869..3ad2582e36b 100644 --- a/app/assets/javascripts/ci/jobs_page/components/job_cells/actions_cell.vue +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/actions_cell.vue @@ -7,7 +7,7 @@ import { GlSprintf, GlTooltipDirective, } from '@gitlab/ui'; -import { reportMessageToSentry } from '~/ci/utils'; +import { reportToSentry } from '~/ci/utils'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated import { @@ -133,7 +133,7 @@ export default { variables: { id: this.job.id }, }); if (errors.length > 0) { - reportMessageToSentry(this.$options.name, errors.join(', '), {}); + reportToSentry(this.$options.name, new Error(errors.join(', '))); this.showToastMessage(); } else if (redirect) { // Retry and Play actions redirect to job detail view @@ -143,7 +143,7 @@ export default { eventHub.$emit('jobActionPerformed'); } } catch (failure) { - reportMessageToSentry(this.$options.name, failure, {}); + reportToSentry(this.$options.name, failure); this.showToastMessage(); } }, diff --git a/app/assets/javascripts/ci/jobs_page/components/job_cells/job_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/job_cell.vue index b435eb283fd..fbdfc7c9c6a 100644 --- a/app/assets/javascripts/ci/jobs_page/components/job_cells/job_cell.vue +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/job_cell.vue @@ -35,9 +35,6 @@ export default { jobRef() { return this.job?.refName; }, - jobRefPath() { - return this.job?.refPath; - }, jobTags() { return this.job.tags; }, @@ -72,61 +69,60 @@ export default { <template> <div> <div class="gl-text-truncate gl-p-3 gl-mt-n3 gl-mx-n3 gl-mb-n2"> - <gl-link - v-if="canReadJob" - class="gl-text-blue-600!" - :href="jobPath" - data-testid="job-id-link" - > - {{ jobId }} - </gl-link> - - <span v-else data-testid="job-id-limited-access">{{ jobId }}</span> - <gl-icon v-if="jobStuck" v-gl-tooltip="$options.i18n.stuckText" name="warning" :size="$options.iconSize" + class="gl-mr-2" data-testid="stuck-icon" /> - <div - class="gl-display-flex gl-text-gray-700 gl-align-items-center gl-lg-justify-content-start gl-justify-content-end gl-mt-2" + <gl-link + v-if="canReadJob" + class="gl-text-blue-600!" + :href="jobPath" + data-testid="job-id-link" > - <div - v-if="jobRef" - class="gl-p-2 gl-rounded-base gl-bg-gray-50 gl-max-w-15 gl-text-truncate" - > - <gl-icon - v-if="createdByTag" - name="label" - :size="$options.iconSize" - data-testid="label-icon" - /> - <gl-icon v-else name="fork" :size="$options.iconSize" data-testid="fork-icon" /> - <gl-link - class="gl-font-sm gl-font-monospace gl-text-gray-700 gl-hover-text-gray-900" - :href="job.refPath" - data-testid="job-ref" - >{{ job.refName }}</gl-link - > - </div> + <span class="gl-text-truncate"> + <span data-testid="job-name">{{ jobId }}: {{ job.name }}</span> + </span> + </gl-link> - <span v-else>{{ __('none') }}</span> - <div class="gl-ml-2 gl-p-2 gl-rounded-base gl-bg-gray-50"> - <gl-icon class="gl-mx-2" name="commit" :size="$options.iconSize" /> - <gl-link - class="gl-font-sm gl-font-monospace gl-text-gray-700 gl-hover-text-gray-900" - :href="job.commitPath" - data-testid="job-sha" - >{{ job.shortSha }}</gl-link - > - </div> + <span v-else data-testid="job-id-limited-access">{{ jobId }}: {{ job.name }}</span> + </div> + + <div + class="gl-display-flex gl-text-gray-700 gl-align-items-center gl-lg-justify-content-start gl-justify-content-end gl-mt-1" + > + <div v-if="jobRef" class="gl-p-2 gl-rounded-base gl-bg-gray-50 gl-max-w-26 gl-text-truncate"> + <gl-icon + v-if="createdByTag" + name="label" + :size="$options.iconSize" + data-testid="label-icon" + /> + <gl-icon v-else name="fork" :size="$options.iconSize" data-testid="fork-icon" /> + <gl-link + class="gl-font-sm gl-font-monospace gl-text-gray-700 gl-hover-text-gray-900" + :href="job.refPath" + data-testid="job-ref" + >{{ job.refName }}</gl-link + > + </div> + <span v-else>{{ __('none') }}</span> + <div class="gl-ml-2 gl-p-2 gl-rounded-base gl-bg-gray-50"> + <gl-icon class="gl-mx-2" name="commit" :size="$options.iconSize" /> + <gl-link + class="gl-font-sm gl-font-monospace gl-text-gray-700 gl-hover-text-gray-900" + :href="job.commitPath" + data-testid="job-sha" + >{{ job.shortSha }}</gl-link + > </div> </div> - <div> + <div class="gl-mt-2"> <gl-badge v-for="tag in jobTags" :key="tag" @@ -136,7 +132,6 @@ export default { > {{ tag }} </gl-badge> - <gl-badge v-if="triggered" variant="info" diff --git a/app/assets/javascripts/ci/jobs_page/components/job_cells/pipeline_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/pipeline_cell.vue index 18d68ee8a29..945674153c4 100644 --- a/app/assets/javascripts/ci/jobs_page/components/job_cells/pipeline_cell.vue +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/pipeline_cell.vue @@ -1,8 +1,12 @@ <script> import { GlAvatar, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; export default { + i18n: { + stageLabel: s__('Jobs|Stage'), + }, components: { GlAvatar, GlLink, @@ -36,21 +40,22 @@ export default { <template> <div> - <div class="gl-p-3 gl-mt-n3"> - <gl-link - class="gl-text-truncate gl-ml-n3 gl-text-gray-500!" - :href="pipelinePath" - data-testid="pipeline-id" - > + <div class="gl-p-3 gl-mt-n3 gl-mx-n3"> + <gl-link class="gl-text-truncate" :href="pipelinePath" data-testid="pipeline-id"> {{ pipelineId }} </gl-link> + + <span class="gl-text-secondary"> + <span>{{ __('created by') }}</span> + <gl-link v-if="showAvatar" :href="userPath" data-testid="pipeline-user-link"> + <gl-avatar :src="pipelineUserAvatar" :size="16" /> + </gl-link> + <span v-else>{{ __('API') }}</span> + </span> </div> - <div class="gl-font-sm gl-text-secondary gl-mt-n2"> - <span>{{ __('created by') }}</span> - <gl-link v-if="showAvatar" :href="userPath" data-testid="pipeline-user-link"> - <gl-avatar :src="pipelineUserAvatar" :size="16" /> - </gl-link> - <span v-else>{{ __('API') }}</span> + + <div v-if="job.stage" class="gl-text-truncate gl-font-sm gl-text-secondary gl-mt-1"> + <span data-testid="job-stage-name">{{ $options.i18n.stageLabel }}: {{ job.stage.name }}</span> </div> </div> </template> diff --git a/app/assets/javascripts/ci/jobs_page/components/job_cells/duration_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/status_cell.vue index dbf1dfe7a29..a2b6a430138 100644 --- a/app/assets/javascripts/ci/jobs_page/components/job_cells/duration_cell.vue +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/status_cell.vue @@ -1,12 +1,14 @@ <script> import { GlIcon } from '@gitlab/ui'; import { formatTime } from '~/lib/utils/datetime_utility'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; export default { iconSize: 12, components: { + CiBadgeLink, GlIcon, TimeAgoTooltip, }, @@ -36,17 +38,16 @@ export default { <template> <div> - <div v-if="duration" data-testid="job-duration"> - <gl-icon name="timer" :size="$options.iconSize" data-testid="duration-icon" /> - {{ durationFormatted }} - </div> - <div - v-if="finishedTime" - :class="{ 'gl-mt-2': hasDurationAndFinishedTime }" - data-testid="job-finished-time" - > - <gl-icon name="calendar" :size="$options.iconSize" data-testid="finished-time-icon" /> - <time-ago-tooltip :time="finishedTime" /> + <ci-badge-link :status="job.detailedStatus" /> + <div class="gl-font-sm gl-text-secondary gl-mt-2 gl-ml-3"> + <div v-if="duration" data-testid="job-duration"> + <gl-icon name="timer" :size="$options.iconSize" data-testid="duration-icon" /> + {{ durationFormatted }} + </div> + <div v-if="finishedTime" data-testid="job-finished-time"> + <gl-icon name="calendar" :size="$options.iconSize" data-testid="finished-time-icon" /> + <time-ago-tooltip :time="finishedTime" /> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/ci/jobs_page/components/jobs_table.vue b/app/assets/javascripts/ci/jobs_page/components/jobs_table.vue index 23100a3f3db..d81d19cfd52 100644 --- a/app/assets/javascripts/ci/jobs_page/components/jobs_table.vue +++ b/app/assets/javascripts/ci/jobs_page/components/jobs_table.vue @@ -1,12 +1,11 @@ <script> import { GlTable } from '@gitlab/ui'; import { s__ } from '~/locale'; -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import ProjectCell from '~/ci/admin/jobs_table/components/cells/project_cell.vue'; import RunnerCell from '~/ci/admin/jobs_table/components/cells/runner_cell.vue'; -import { DEFAULT_FIELDS } from '../constants'; +import { JOBS_DEFAULT_FIELDS } from '../constants'; import ActionsCell from './job_cells/actions_cell.vue'; -import DurationCell from './job_cells/duration_cell.vue'; +import StatusCell from './job_cells/status_cell.vue'; import JobCell from './job_cells/job_cell.vue'; import PipelineCell from './job_cells/pipeline_cell.vue'; @@ -16,13 +15,12 @@ export default { }, components: { ActionsCell, - CiBadgeLink, - DurationCell, - GlTable, + StatusCell, JobCell, PipelineCell, ProjectCell, RunnerCell, + GlTable, }, props: { jobs: { @@ -32,7 +30,7 @@ export default { tableFields: { type: Array, required: false, - default: () => DEFAULT_FIELDS, + default: () => JOBS_DEFAULT_FIELDS, }, admin: { type: Boolean, @@ -64,7 +62,7 @@ export default { </template> <template #cell(status)="{ item }"> - <ci-badge-link :status="item.detailedStatus" /> + <status-cell :job="item" /> </template> <template #cell(job)="{ item }"> @@ -75,28 +73,20 @@ export default { <pipeline-cell :job="item" /> </template> - <template v-if="admin" #cell(project)="{ item }"> - <project-cell :job="item" /> - </template> - - <template v-if="admin" #cell(runner)="{ item }"> - <runner-cell :job="item" /> - </template> - <template #cell(stage)="{ item }"> <div class="gl-text-truncate"> - <span v-if="item.stage" data-testid="job-stage-name">{{ item.stage.name }}</span> + <span v-if="item.stage" data-testid="job-stage-name" class="gl-text-secondary">{{ + item.stage.name + }}</span> </div> </template> - <template #cell(name)="{ item }"> - <div class="gl-text-truncate"> - <span data-testid="job-name">{{ item.name }}</span> - </div> + <template v-if="admin" #cell(project)="{ item }"> + <project-cell :job="item" /> </template> - <template #cell(duration)="{ item }"> - <duration-cell :job="item" /> + <template v-if="admin" #cell(runner)="{ item }"> + <runner-cell :job="item" /> </template> <template #cell(coverage)="{ item }"> diff --git a/app/assets/javascripts/ci/jobs_page/components/jobs_table_empty_state.vue b/app/assets/javascripts/ci/jobs_page/components/jobs_table_empty_state.vue index d2cd27be034..7effb8fe239 100644 --- a/app/assets/javascripts/ci/jobs_page/components/jobs_table_empty_state.vue +++ b/app/assets/javascripts/ci/jobs_page/components/jobs_table_empty_state.vue @@ -29,6 +29,7 @@ export default { :title="$options.i18n.title" :description="$options.i18n.description" :svg-path="emptyStateSvgPath" + :svg-height="null" :primary-button-link="pipelineEditorPath" :primary-button-text="$options.i18n.buttonText" data-testid="jobs-empty-state" diff --git a/app/assets/javascripts/ci/jobs_page/constants.js b/app/assets/javascripts/ci/jobs_page/constants.js index 1b572e60c58..dec355ddff6 100644 --- a/app/assets/javascripts/ci/jobs_page/constants.js +++ b/app/assets/javascripts/ci/jobs_page/constants.js @@ -29,6 +29,7 @@ export const PLAY_JOB_CONFIRMATION_MESSAGE = s__( export const RUN_JOB_NOW_HEADER_TITLE = s__('DelayedJobs|Run the delayed job now?'); /* Table constants */ +/* There is another field list based on this one in app/assets/javascripts/ci/admin/jobs_table/constants.js */ export const DEFAULT_FIELDS = [ { key: 'status', @@ -38,7 +39,7 @@ export const DEFAULT_FIELDS = [ { key: 'job', label: __('Job'), - columnClass: 'gl-w-20p', + columnClass: 'gl-w-quarter', }, { key: 'pipeline', @@ -51,16 +52,6 @@ export const DEFAULT_FIELDS = [ columnClass: 'gl-w-10p', }, { - key: 'name', - label: __('Name'), - columnClass: 'gl-w-15p', - }, - { - key: 'duration', - label: __('Duration'), - columnClass: 'gl-w-15p', - }, - { key: 'coverage', label: __('Coverage'), tdClass: 'gl-display-none! gl-lg-display-table-cell!', @@ -69,8 +60,10 @@ export const DEFAULT_FIELDS = [ { key: 'actions', label: '', + tdClass: 'gl-text-right', columnClass: 'gl-w-10p', }, ]; +export const JOBS_DEFAULT_FIELDS = DEFAULT_FIELDS.filter((field) => field.key !== 'stage'); export const JOBS_TAB_FIELDS = DEFAULT_FIELDS.filter((field) => field.key !== 'pipeline'); diff --git a/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_retry.mutation.graphql b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_retry.mutation.graphql index 6e51f9a20fa..077c8e31749 100644 --- a/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_retry.mutation.graphql +++ b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_retry.mutation.graphql @@ -1,6 +1,6 @@ #import "../fragments/job.fragment.graphql" -mutation retryJob($id: CiBuildID!) { +mutation retryJob($id: CiProcessableID!) { jobRetry(input: { id: $id }) { job { ...Job diff --git a/app/assets/javascripts/ci/merge_requests/graphql/mutations/retry_mr_failed_job.mutation.graphql b/app/assets/javascripts/ci/merge_requests/graphql/mutations/retry_mr_failed_job.mutation.graphql index 022d461dbec..f6de6cde9d0 100644 --- a/app/assets/javascripts/ci/merge_requests/graphql/mutations/retry_mr_failed_job.mutation.graphql +++ b/app/assets/javascripts/ci/merge_requests/graphql/mutations/retry_mr_failed_job.mutation.graphql @@ -1,4 +1,4 @@ -mutation retryMrFailedJob($id: CiBuildID!) { +mutation retryMrFailedJob($id: CiProcessableID!) { jobRetry(input: { id: $id }) { errors } diff --git a/app/assets/javascripts/ci/pipeline_details/constants.js b/app/assets/javascripts/ci/pipeline_details/constants.js index bf312e66144..70b758ae6b0 100644 --- a/app/assets/javascripts/ci/pipeline_details/constants.js +++ b/app/assets/javascripts/ci/pipeline_details/constants.js @@ -23,8 +23,6 @@ export const PARSE_FAILURE = 'parse_failure'; export const POST_FAILURE = 'post_failure'; export const UNSUPPORTED_DATA = 'unsupported_data'; -export const CHILD_VIEW = 'child'; - // Pipeline tabs export const pipelineTabName = 'graph'; diff --git a/app/assets/javascripts/ci/pipeline_details/dag/dag.vue b/app/assets/javascripts/ci/pipeline_details/dag/dag.vue index 5415340c956..fb8e5d679b7 100644 --- a/app/assets/javascripts/ci/pipeline_details/dag/dag.vue +++ b/app/assets/javascripts/ci/pipeline_details/dag/dag.vue @@ -220,6 +220,7 @@ export default { <gl-empty-state v-else-if="hasNoDependentJobs" :svg-path="emptyDagSvgPath" + :svg-height="null" :title="$options.emptyStateTexts.title" > <template #description> diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/job_group_dropdown.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/job_group_dropdown.vue index 7538ad87af8..ec8f30e94b4 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/job_group_dropdown.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/job_group_dropdown.vue @@ -65,7 +65,7 @@ export default { <div :id="computedJobId" class="ci-job-dropdown-container dropdown dropright" - data-qa-selector="job_dropdown_container" + data-testid="job-dropdown-container" > <button type="button" @@ -90,7 +90,7 @@ export default { <ul class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown" - data-qa-selector="jobs_dropdown_menu" + data-testid="jobs-dropdown-menu" > <li class="scrollable-menu"> <ul> diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue index 4298052d1c0..bb36ac8b6ab 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue @@ -5,7 +5,7 @@ import delayedJobMixin from '~/ci/mixins/delayed_job_mixin'; import { helpPagePath } from '~/helpers/help_page_helper'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { __, s__, sprintf } from '~/locale'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import ActionComponent from '../../../common/private/job_action_component.vue'; import JobNameComponent from '../../../common/private/job_name_component.vue'; import { BRIDGE_KIND, RETRY_ACTION_TITLE, SINGLE_JOB, SKIP_RETRY_MODAL_KEY } from '../constants'; @@ -58,7 +58,7 @@ export default { hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500', components: { ActionComponent, - CiIcon, + CiBadgeLink, GlBadge, GlForm, GlFormCheckbox, @@ -312,7 +312,6 @@ export default { <div :id="computedJobId" class="ci-job-component gl-display-flex gl-justify-content-space-between gl-pipeline-job-width" - data-qa-selector="job_item_container" > <component :is="nameComponent" @@ -326,12 +325,11 @@ export default { :href="detailsPath" class="js-pipeline-graph-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none gl-w-full" :data-testid="testId" - data-qa-selector="job_link" @click="jobItemClick" @mouseout="hideTooltips" > <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" /> + <ci-badge-link :status="job.status" size="md" :show-text="false" :use-link="false" /> <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> <div @@ -343,7 +341,13 @@ export default { </div> </div> </div> - <gl-badge v-if="isBridge" class="gl-mt-3" variant="info" size="sm"> + <gl-badge + v-if="isBridge" + class="gl-mt-3" + variant="info" + size="sm" + data-testid="job-bridge-badge" + > {{ $options.i18n.bridgeBadgeText }} </gl-badge> </component> @@ -356,7 +360,6 @@ export default { class="gl-mr-1" :should-trigger-click="shouldTriggerActionClick" :with-confirmation-modal="withConfirmationModal" - data-qa-selector="job_action_button" @actionButtonClicked="handleConfirmationModalPreferences" @pipelineActionRequestComplete="pipelineActionRequestComplete" @showActionConfirmationModal="showActionConfirmationModal" diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue index d6adaf78da4..5960eea5b4f 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue @@ -13,7 +13,7 @@ import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { __, sprintf } from '~/locale'; import CancelPipelineMutation from '~/ci/pipeline_details/graphql/mutations/cancel_pipeline.mutation.graphql'; import RetryPipelineMutation from '~/ci/pipeline_details/graphql/mutations/retry_pipeline.mutation.graphql'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import { reportToSentry } from '~/ci/utils'; import { ACTION_FAILURE, DOWNSTREAM, UPSTREAM } from '../constants'; @@ -22,7 +22,7 @@ export default { GlTooltip: GlTooltipDirective, }, components: { - CiIcon, + CiBadgeLink, GlBadge, GlButton, GlLink, @@ -233,7 +233,7 @@ export default { ref="linkedPipeline" class="gl-h-full gl-display-flex! gl-px-2" :class="flexDirection" - data-qa-selector="linked_pipeline_container" + data-testid="linked-pipeline-container" @mouseover="onDownstreamHovered" @mouseleave="onDownstreamHoverLeave" > @@ -242,16 +242,19 @@ export default { </gl-tooltip> <div class="gl-bg-white gl-border gl-p-3 gl-rounded-lg gl-w-full" :class="cardClasses"> <div class="gl-display-flex gl-gap-x-3"> - <ci-icon v-if="!pipelineIsLoading" :status="pipelineStatus" :size="24" /> + <ci-badge-link + v-if="!pipelineIsLoading" + :status="pipelineStatus" + size="md" + :show-text="false" + :use-link="false" + class="gl-align-self-start" + /> <div v-else class="gl-pr-3"><gl-loading-icon size="sm" inline /></div> <div class="gl-display-flex gl-downstream-pipeline-job-width gl-flex-direction-column gl-line-height-normal" > - <span - class="gl-text-truncate" - data-testid="downstream-title" - data-qa-selector="downstream_title_content" - > + <span class="gl-text-truncate" data-testid="downstream-title-content"> {{ downstreamTitle }} </span> <div class="gl-text-truncate"> @@ -294,7 +297,6 @@ export default { :icon="expandedIcon" :aria-label="expandBtnText" data-testid="expand-pipeline-button" - data-qa-selector="expand_linked_pipeline_button" @mouseover="setExpandBtnActiveState(true)" @mouseout="setExpandBtnActiveState(false)" @focus="setExpandBtnActiveState(true)" diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue index 1401bdba5ca..6030adc96ad 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue @@ -179,6 +179,7 @@ export default { { 'gl-opacity-3': isFadedOut(group.name) }, 'gl-transition-duration-slow gl-transition-timing-function-ease', ]" + data-testid="job-item-container" @pipelineActionRequestComplete="$emit('refreshPipelineGraph')" @setSkipRetryModal="$emit('setSkipRetryModal')" /> diff --git a/app/assets/javascripts/ci/pipeline_details/graph/graph_component_wrapper.vue b/app/assets/javascripts/ci/pipeline_details/graph/graph_component_wrapper.vue index bd7325f7925..a6e7a645442 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/graph_component_wrapper.vue @@ -6,7 +6,7 @@ import { __, s__ } from '~/locale'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '~/ci/pipeline_details/constants'; import getPipelineQuery from '~/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql'; -import { reportToSentry, reportMessageToSentry } from '~/ci/utils'; +import { reportToSentry } from '~/ci/utils'; import DismissPipelineGraphCallout from './graphql/mutations/dismiss_pipeline_notification.graphql'; import { ACTION_FAILURE, @@ -156,17 +156,7 @@ export default { error(err) { this.reportFailure({ type: LOAD_FAILURE, skipSentry: true }); - reportMessageToSentry( - this.$options.name, - `| type: ${LOAD_FAILURE} , info: ${JSON.stringify(err)}`, - { - graphViewType: this.graphViewType, - graphqlResourceEtag: this.graphqlResourceEtag, - metricsPath: this.metricsPath, - projectPath: this.pipelineProjectPath, - pipelineIid: this.pipelineIid, - }, - ); + reportToSentry(this.$options.name, new Error(err)); }, result({ data, error }) { const stages = data?.project?.pipeline?.stages?.nodes || []; diff --git a/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue b/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue index 3a6a655bfa6..51a68f6619a 100644 --- a/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue +++ b/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue @@ -396,18 +396,14 @@ export default { </div> </gl-alert> <gl-loading-icon v-if="loading" class="gl-text-left" size="lg" /> - <div - v-else - class="gl-display-flex gl-justify-content-space-between gl-flex-wrap" - data-qa-selector="pipeline_details_header" - > + <div v-else class="gl-display-flex gl-justify-content-space-between gl-flex-wrap"> <div> <h3 v-if="name" class="gl-mt-0 gl-mb-3" data-testid="pipeline-name">{{ name }}</h3> <h3 v-else class="gl-mt-0 gl-mb-3" data-testid="pipeline-commit-title"> {{ commitTitle }} </h3> <div> - <ci-badge-link :status="detailedStatus" /> + <ci-badge-link :status="detailedStatus" class="gl-display-inline-block gl-mb-3" /> <div class="gl-ml-2 gl-mb-3 gl-display-inline-block gl-h-6"> <gl-link v-if="user" @@ -423,7 +419,7 @@ export default { <template #link="{ content }"> <gl-link :href="commitPath" - class="gl-bg-blue-50 gl-rounded-base gl-px-2 gl-mx-2" + class="commit-sha-container" data-testid="commit-link" target="_blank" > @@ -431,6 +427,8 @@ export default { </gl-link> </template> </gl-sprintf> + </div> + <div class="gl-display-inline-block gl-mb-3"> <clipboard-button :text="shortId" category="tertiary" @@ -449,123 +447,127 @@ export default { </div> <div v-safe-html="refText" class="gl-mb-3" data-testid="pipeline-ref-text"></div> <div> - <gl-badge - v-if="badges.schedule" - v-gl-tooltip - :title="$options.i18n.scheduleBadgeTooltip" - variant="info" - size="sm" - > - {{ $options.i18n.scheduleBadgeText }} - </gl-badge> - <gl-badge - v-if="badges.child" - v-gl-tooltip - :title="$options.i18n.childBadgeTooltip" - variant="info" - size="sm" - > - <gl-sprintf :message="$options.i18n.childBadgeText"> - <template #link="{ content }"> - <gl-link :href="paths.triggeredByPath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </gl-badge> - <gl-badge - v-if="badges.latest" - v-gl-tooltip - :title="$options.i18n.latestBadgeTooltip" - variant="success" - size="sm" - > - {{ $options.i18n.latestBadgeText }} - </gl-badge> - <gl-badge - v-if="badges.mergeTrainPipeline" - v-gl-tooltip - :title="$options.i18n.mergeTrainBadgeTooltip" - variant="info" - size="sm" - > - {{ $options.i18n.mergeTrainBadgeText }} - </gl-badge> - <gl-badge - v-if="badges.invalid" - v-gl-tooltip - :title="yamlErrors" - variant="danger" - size="sm" - > - {{ $options.i18n.invalidBadgeText }} - </gl-badge> - <gl-badge - v-if="badges.failed" - v-gl-tooltip - :title="failureReason" - variant="danger" - size="sm" - > - {{ $options.i18n.failedBadgeText }} - </gl-badge> - <gl-badge - v-if="badges.autoDevops" - v-gl-tooltip - :title="$options.i18n.autoDevopsBadgeTooltip" - variant="info" - size="sm" - > - {{ $options.i18n.autoDevopsBadgeText }} - </gl-badge> - <gl-badge - v-if="badges.detached" - v-gl-tooltip - :title="$options.i18n.detachedBadgeTooltip" - variant="info" - size="sm" - > - {{ $options.i18n.detachedBadgeText }} - </gl-badge> - <gl-badge - v-if="badges.stuck" - v-gl-tooltip - :title="$options.i18n.stuckBadgeTooltip" - variant="warning" - size="sm" - > - {{ $options.i18n.stuckBadgeText }} - </gl-badge> - <span - v-gl-tooltip - :title="$options.i18n.totalJobsTooltip" - class="gl-ml-2" - data-testid="total-jobs" - > - <gl-icon name="pipeline" /> - {{ totalJobsText }} - </span> - <span - v-if="showComputeMinutes" - v-gl-tooltip - :title="$options.i18n.computeMinutesTooltip" - class="gl-ml-2" - data-testid="compute-minutes" - > - <gl-icon name="quota" /> - {{ computeMinutes }} - </span> - <span v-if="inProgress" class="gl-ml-2" data-testid="pipeline-running-text"> - <gl-icon name="timer" /> - {{ inProgressText }} - </span> - <span v-if="showDuration" class="gl-ml-2" data-testid="pipeline-duration-text"> - <gl-icon name="timer" /> - {{ durationText }} - </span> + <div class="gl-display-inline-block gl-mb-3"> + <gl-badge + v-if="badges.schedule" + v-gl-tooltip + :title="$options.i18n.scheduleBadgeTooltip" + variant="info" + size="sm" + > + {{ $options.i18n.scheduleBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.child" + v-gl-tooltip + :title="$options.i18n.childBadgeTooltip" + variant="info" + size="sm" + > + <gl-sprintf :message="$options.i18n.childBadgeText"> + <template #link="{ content }"> + <gl-link :href="paths.triggeredByPath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-badge> + <gl-badge + v-if="badges.latest" + v-gl-tooltip + :title="$options.i18n.latestBadgeTooltip" + variant="success" + size="sm" + > + {{ $options.i18n.latestBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.mergeTrainPipeline" + v-gl-tooltip + :title="$options.i18n.mergeTrainBadgeTooltip" + variant="info" + size="sm" + > + {{ $options.i18n.mergeTrainBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.invalid" + v-gl-tooltip + :title="yamlErrors" + variant="danger" + size="sm" + > + {{ $options.i18n.invalidBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.failed" + v-gl-tooltip + :title="failureReason" + variant="danger" + size="sm" + > + {{ $options.i18n.failedBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.autoDevops" + v-gl-tooltip + :title="$options.i18n.autoDevopsBadgeTooltip" + variant="info" + size="sm" + > + {{ $options.i18n.autoDevopsBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.detached" + v-gl-tooltip + :title="$options.i18n.detachedBadgeTooltip" + variant="info" + size="sm" + > + {{ $options.i18n.detachedBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.stuck" + v-gl-tooltip + :title="$options.i18n.stuckBadgeTooltip" + variant="warning" + size="sm" + > + {{ $options.i18n.stuckBadgeText }} + </gl-badge> + </div> + <div class="gl-display-inline-block"> + <span + v-gl-tooltip + :title="$options.i18n.totalJobsTooltip" + class="gl-ml-2" + data-testid="total-jobs" + > + <gl-icon name="pipeline" /> + {{ totalJobsText }} + </span> + <span + v-if="showComputeMinutes" + v-gl-tooltip + :title="$options.i18n.computeMinutesTooltip" + class="gl-ml-2" + data-testid="compute-minutes" + > + <gl-icon name="quota" /> + {{ computeMinutes }} + </span> + <span v-if="inProgress" class="gl-ml-2" data-testid="pipeline-running-text"> + <gl-icon name="timer" /> + {{ inProgressText }} + </span> + <span v-if="showDuration" class="gl-ml-2" data-testid="pipeline-duration-text"> + <gl-icon name="timer" /> + {{ durationText }} + </span> + </div> </div> </div> - <div class="gl-mt-5 gl-lg-mt-0"> + <div class="gl-mt-5 gl-lg-mt-0 gl-display-flex gl-align-items-flex-start gl-gap-3"> <gl-button v-if="canRetryPipeline" v-gl-tooltip @@ -588,7 +590,6 @@ export default { :title="$options.BUTTON_TOOLTIP_CANCEL" :loading="isCanceling" :disabled="isCanceling" - class="gl-ml-3" variant="danger" data-testid="cancel-pipeline" @click="cancelPipeline()" @@ -601,7 +602,6 @@ export default { v-gl-modal="$options.modal.id" :loading="isDeleting" :disabled="isDeleting" - class="gl-ml-3" variant="danger" category="secondary" data-testid="delete-pipeline" diff --git a/app/assets/javascripts/ci/pipeline_details/jobs/graphql/mutations/retry_failed_job.mutation.graphql b/app/assets/javascripts/ci/pipeline_details/jobs/graphql/mutations/retry_failed_job.mutation.graphql index 1955cc9b0ac..b60afe51dd2 100644 --- a/app/assets/javascripts/ci/pipeline_details/jobs/graphql/mutations/retry_failed_job.mutation.graphql +++ b/app/assets/javascripts/ci/pipeline_details/jobs/graphql/mutations/retry_failed_job.mutation.graphql @@ -1,4 +1,4 @@ -mutation retryFailedJob($id: CiBuildID!) { +mutation retryFailedJob($id: CiProcessableID!) { jobRetry(input: { id: $id }) { job { id diff --git a/app/assets/javascripts/ci/pipeline_details/mixins/pipelines_mixin.js b/app/assets/javascripts/ci/pipeline_details/mixins/pipelines_mixin.js index 53f755fda37..5d1f1ac770c 100644 --- a/app/assets/javascripts/ci/pipeline_details/mixins/pipelines_mixin.js +++ b/app/assets/javascripts/ci/pipeline_details/mixins/pipelines_mixin.js @@ -52,14 +52,12 @@ export default { }); eventHub.$on('postAction', this.postAction); - eventHub.$on('retryPipeline', this.postAction); eventHub.$on('clickedDropdown', this.updateTable); eventHub.$on('updateTable', this.updateTable); eventHub.$on('runMergeRequestPipeline', this.runMergeRequestPipeline); }, beforeDestroy() { eventHub.$off('postAction', this.postAction); - eventHub.$off('retryPipeline', this.postAction); eventHub.$off('clickedDropdown', this.updateTable); eventHub.$off('updateTable', this.updateTable); eventHub.$off('runMergeRequestPipeline', this.runMergeRequestPipeline); @@ -68,6 +66,15 @@ export default { this.poll.stop(); }, methods: { + onCancelPipeline(pipeline) { + this.postAction(pipeline.cancel_path); + }, + onRefreshPipelinesTable() { + this.updateTable(); + }, + onRetryPipeline(pipeline) { + this.postAction(pipeline.retry_path); + }, updateInternalState(parameters) { this.poll.stop(); diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_index.js b/app/assets/javascripts/ci/pipeline_details/pipelines_index.js index d38397e7479..8a7c3367fc1 100644 --- a/app/assets/javascripts/ci/pipeline_details/pipelines_index.js +++ b/app/assets/javascripts/ci/pipeline_details/pipelines_index.js @@ -31,10 +31,7 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { endpoint, artifactsEndpoint, artifactsEndpointPlaceholder, - pipelineScheduleUrl, - emptyStateSvgPath, - errorStateSvgPath, - noPipelinesSvgPath, + pipelineSchedulesPath, newPipelinePath, pipelineEditorPath, suggestedCiTemplates, @@ -55,13 +52,14 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { el, apolloProvider, provide: { - pipelineEditorPath, artifactsEndpoint, artifactsEndpointPlaceholder, - suggestedCiTemplates: JSON.parse(suggestedCiTemplates), - iosRunnersAvailable: parseBoolean(iosRunnersAvailable), fullPath, + iosRunnersAvailable: parseBoolean(iosRunnersAvailable), manualActionsLimit: 50, + pipelineEditorPath, + pipelineSchedulesPath, + suggestedCiTemplates: JSON.parse(suggestedCiTemplates), }, data() { return { @@ -77,22 +75,18 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { render(createElement) { return createElement(Pipelines, { props: { - store: this.store, - endpoint, - pipelineScheduleUrl, - emptyStateSvgPath, - errorStateSvgPath, - noPipelinesSvgPath, - newPipelinePath, canCreatePipeline: parseBoolean(canCreatePipeline), - hasGitlabCi: parseBoolean(hasGitlabCi), ciLintPath, - resetCachePath, - projectId, defaultBranchName, + defaultVisibilityPipelineIdType: visibilityPipelineIdType, + endpoint, + hasGitlabCi: parseBoolean(hasGitlabCi), + newPipelinePath, params: JSON.parse(params), + projectId, registrationToken, - defaultVisibilityPipelineIdType: visibilityPipelineIdType, + resetCachePath, + store: this.store, }, }); }, diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue index 58b5c0004e0..44cf11acfe2 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue @@ -7,7 +7,7 @@ import getPipelineQuery from '~/ci/pipeline_editor/graphql/queries/pipeline.quer import getPipelineEtag from '~/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql'; import { getQueryHeaders, toggleQueryPollingByVisibility } from '~/ci/pipeline_details/graph/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import PipelineMiniGraph from '~/ci/pipeline_mini_graph/pipeline_mini_graph.vue'; import PipelineEditorMiniGraph from './pipeline_editor_mini_graph.vue'; @@ -25,7 +25,7 @@ export const i18n = { export default { i18n, components: { - CiIcon, + CiBadgeLink, GlButton, GlIcon, GlLink, @@ -156,7 +156,12 @@ export default { <template v-else> <div class="gl-text-truncate gl-md-max-w-50p gl-mr-1"> <a :href="status.detailsPath" class="gl-mr-auto"> - <ci-icon :status="status" :size="16" data-testid="pipeline-status-icon" class="gl-mr-2" /> + <ci-badge-link + :status="status" + size="md" + :show-text="false" + data-testid="pipeline-status-icon" + /> </a> <span class="gl-font-weight-bold"> <gl-sprintf :message="$options.i18n.pipelineInfo"> diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_stage.vue b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_stage.vue index bbe0f1fbefc..34640d49b80 100644 --- a/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_stage.vue +++ b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_stage.vue @@ -13,7 +13,7 @@ */ import { GlDropdown, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import { createAlert } from '~/alert'; import eventHub from '~/ci/event_hub'; import axios from '~/lib/utils/axios_utils'; @@ -33,7 +33,7 @@ export default { positionFixed: true, }, components: { - CiIcon, + CiBadgeLink, GlLoadingIcon, GlDropdown, LegacyJobItem, @@ -126,14 +126,13 @@ export default { @show="onShowDropdown" > <template #button-content> - <ci-icon - is-borderless - is-interactive - css-classes="gl-rounded-full" - :is-active="isDropdownOpen" - :size="24" + <ci-badge-link :status="stage.status" - class="gl-display-inline-flex gl-align-items-center gl-border gl-z-index-1" + size="md" + :show-text="false" + :show-tooltip="false" + :use-link="false" + class="gl-mb-0!" /> </template> <div v-if="isLoading" class="gl--flex-center gl-p-2" data-testid="pipeline-stage-loading-state"> diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/linked_pipelines_mini_list.vue b/app/assets/javascripts/ci/pipeline_mini_graph/linked_pipelines_mini_list.vue index 8567654a89e..cc703d29e23 100644 --- a/app/assets/javascripts/ci/pipeline_mini_graph/linked_pipelines_mini_list.vue +++ b/app/assets/javascripts/ci/pipeline_mini_graph/linked_pipelines_mini_list.vue @@ -1,7 +1,7 @@ <script> import { GlTooltipDirective } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import { accessValue } from './accessors/linked_pipelines_accessors'; /** * Renders the upstream/downstream portions of the pipeline mini graph. @@ -11,7 +11,7 @@ export default { GlTooltip: GlTooltipDirective, }, components: { - CiIcon, + CiBadgeLink, }, inject: { dataMethod: { @@ -99,24 +99,18 @@ export default { }" class="linked-pipeline-mini-list gl-display-inline gl-vertical-align-middle" > - <a + <ci-badge-link v-for="pipeline in linkedPipelinesTrimmed" :key="pipeline.id" v-gl-tooltip="{ title: pipelineTooltipText(pipeline) }" - :href="pipeline.path" + :status="pipelineStatus(pipeline)" + size="md" + :show-text="false" + :show-tooltip="false" :class="triggerButtonClass(pipeline)" - class="linked-pipeline-mini-item gl-display-inline-flex gl-mr-2 gl-my-2 gl-rounded-full gl-vertical-align-middle" + class="linked-pipeline-mini-item gl-mb-0!" data-testid="linked-pipeline-mini-item" - > - <ci-icon - is-borderless - is-interactive - css-classes="gl-rounded-full" - :size="24" - :status="pipelineStatus(pipeline)" - class="gl-align-items-center gl-border gl-display-inline-flex" - /> - </a> + /> <a v-if="shouldRenderCounter" diff --git a/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue index cc7d9bd2340..2f06b82bac0 100644 --- a/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue @@ -438,8 +438,7 @@ export default { 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" + data-testid="ci-variable-row-container" > <div class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row" @@ -461,8 +460,7 @@ export default { 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" + data-testid="pipeline-form-ci-variable-key-field" @change="addEmptyVariable(refFullName)" /> <gl-dropdown @@ -471,12 +469,11 @@ 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="option in configVariablesWithDescription.options[variable.key]" :key="option" - data-qa-selector="ci_variable_value_dropdown_item" + data-testid="ci-variable-value-dropdown-item" @click="setVariableAttribute(variable.key, 'value', option)" > {{ option }} @@ -489,8 +486,7 @@ export default { class="gl-mb-3" :style="$options.textAreaStyle" :no-resize="false" - data-testid="pipeline-form-ci-variable-value" - data-qa-selector="ci_variable_value_field" + data-testid="pipeline-form-ci-variable-value-field" /> <template v-if="variables.length > 1"> @@ -542,8 +538,7 @@ export default { category="primary" variant="confirm" class="js-no-auto-disable gl-mr-3" - data-qa-selector="run_pipeline_button" - data-testid="run_pipeline_button" + data-testid="run-pipeline-button" :disabled="submitted" >{{ s__('Pipeline|Run pipeline') }}</gl-button > diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue index c993b65f6c0..386835d21d4 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue @@ -4,6 +4,7 @@ import { GlBadge, GlButton, GlLoadingIcon, + GlPagination, GlTabs, GlTab, GlSprintf, @@ -16,12 +17,20 @@ import deletePipelineScheduleMutation from '../graphql/mutations/delete_pipeline import playPipelineScheduleMutation from '../graphql/mutations/play_pipeline_schedule.mutation.graphql'; import takeOwnershipMutation from '../graphql/mutations/take_ownership.mutation.graphql'; import getPipelineSchedulesQuery from '../graphql/queries/get_pipeline_schedules.query.graphql'; -import { ALL_SCOPE } from '../constants'; +import { ALL_SCOPE, SCHEDULES_PER_PAGE } from '../constants'; import PipelineSchedulesTable from './table/pipeline_schedules_table.vue'; import TakeOwnershipModal from './take_ownership_modal.vue'; import DeletePipelineScheduleModal from './delete_pipeline_schedule_modal.vue'; import PipelineScheduleEmptyState from './pipeline_schedules_empty_state.vue'; +const defaultPagination = { + first: SCHEDULES_PER_PAGE, + last: null, + prevPageCursor: '', + nextPageCursor: '', + currentPage: 1, +}; + export default { i18n: { schedulesFetchError: s__('PipelineSchedules|There was a problem fetching pipeline schedules.'), @@ -44,6 +53,7 @@ export default { GlBadge, GlButton, GlLoadingIcon, + GlPagination, GlTabs, GlTab, GlSprintf, @@ -72,16 +82,22 @@ export default { // we need to ensure we send null to the API when // the scope is 'ALL' status: this.scope === ALL_SCOPE ? null : this.scope, + first: this.pagination.first, + last: this.pagination.last, + prevPageCursor: this.pagination.prevPageCursor, + nextPageCursor: this.pagination.nextPageCursor, }; }, update(data) { - const { pipelineSchedules: { nodes: list = [], count } = {} } = data.project || {}; + const { pipelineSchedules: { nodes: list = [], count, pageInfo = {} } = {} } = + data.project || {}; const currentUser = data.currentUser || {}; return { list, count, currentUser, + pageInfo, }; }, error() { @@ -104,6 +120,9 @@ export default { showDeleteModal: false, showTakeOwnershipModal: false, count: 0, + pagination: { + ...defaultPagination, + }, }; }, computed: { @@ -144,6 +163,15 @@ export default { showEmptyState() { return !this.isLoading && this.schedulesCount === 0 && this.onAllTab; }, + showPagination() { + return this.schedules?.pageInfo?.hasNextPage || this.schedules?.pageInfo?.hasPreviousPage; + }, + prevPage() { + return Number(this.schedules?.pageInfo?.hasPreviousPage); + }, + nextPage() { + return Number(this.schedules?.pageInfo?.hasNextPage); + }, }, watch: { // this watcher ensures that the count on the all tab @@ -245,10 +273,36 @@ export default { this.reportError(this.$options.i18n.schedulePlayError); } }, + resetPagination() { + this.pagination = { + ...defaultPagination, + }; + }, fetchPipelineSchedulesByStatus(scope) { this.scope = scope; + this.resetPagination(); this.$apollo.queries.schedules.refetch(); }, + handlePageChange(page) { + const { startCursor, endCursor } = this.schedules.pageInfo; + + if (page > this.pagination.currentPage) { + this.pagination = { + first: SCHEDULES_PER_PAGE, + last: null, + prevPageCursor: '', + nextPageCursor: endCursor, + currentPage: page, + }; + } else { + this.pagination = { + last: SCHEDULES_PER_PAGE, + first: null, + prevPageCursor: startCursor, + currentPage: page, + }; + } + }, }, }; </script> @@ -296,14 +350,25 @@ export default { <gl-loading-icon v-if="isLoading" size="lg" /> - <pipeline-schedules-table - v-else - :schedules="schedules.list" - :current-user="schedules.currentUser" - @showTakeOwnershipModal="setTakeOwnershipModal" - @showDeleteModal="setDeleteModal" - @playPipelineSchedule="playPipelineSchedule" - /> + <template v-else> + <pipeline-schedules-table + :schedules="schedules.list" + :current-user="schedules.currentUser" + @showTakeOwnershipModal="setTakeOwnershipModal" + @showDeleteModal="setDeleteModal" + @playPipelineSchedule="playPipelineSchedule" + /> + + <gl-pagination + v-if="showPagination" + :value="pagination.currentPage" + :prev-page="prevPage" + :next-page="nextPage" + align="center" + class="gl-mt-5" + @input="handlePageChange" + /> + </template> </gl-tab> <template #tabs-end> 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 0c3ede47015..cd1d9a97ef3 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 @@ -370,7 +370,7 @@ export default { /> </gl-form-group> <!--Variable List--> - <gl-form-group class="gl-mb-2" :label="$options.i18n.variables"> + <gl-form-group class="gl-mb-0" :label="$options.i18n.variables"> <div v-for="(variable, index) in variables" :key="`var-${index}`" @@ -456,13 +456,23 @@ export default { <gl-form-checkbox id="schedule-active" v-model="activated" class="gl-mb-3"> {{ $options.i18n.activated }} </gl-form-checkbox> - - <gl-button variant="confirm" data-testid="schedule-submit-button" @click="scheduleHandler"> - {{ buttonText }} - </gl-button> - <gl-button :href="schedulesPath" data-testid="schedule-cancel-button"> - {{ $options.i18n.cancel }} - </gl-button> + <div class="gl-display-flex gl-gap-3 gl-flex-wrap"> + <gl-button + variant="confirm" + data-testid="schedule-submit-button" + class="gl-w-full gl-sm-w-auto" + @click="scheduleHandler" + > + {{ buttonText }} + </gl-button> + <gl-button + :href="schedulesPath" + data-testid="schedule-cancel-button" + class="gl-w-full gl-sm-w-auto" + > + {{ $options.i18n.cancel }} + </gl-button> + </div> </gl-form> </div> </template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/constants.js b/app/assets/javascripts/ci/pipeline_schedules/constants.js index 16dab33ce29..be3feeb6623 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/constants.js +++ b/app/assets/javascripts/ci/pipeline_schedules/constants.js @@ -1,3 +1,4 @@ export const VARIABLE_TYPE = 'ENV_VAR'; export const FILE_TYPE = 'FILE'; export const ALL_SCOPE = 'ALL'; +export const SCHEDULES_PER_PAGE = 50; diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql index 29a26be0344..8fe9fbc5e24 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql +++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql @@ -1,7 +1,13 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + query getPipelineSchedulesQuery( $projectPath: ID! $status: PipelineScheduleStatus $ids: [ID!] = null + $first: Int + $last: Int + $prevPageCursor: String = "" + $nextPageCursor: String = "" ) { currentUser { id @@ -9,7 +15,14 @@ query getPipelineSchedulesQuery( } project(fullPath: $projectPath) { id - pipelineSchedules(status: $status, ids: $ids) { + pipelineSchedules( + status: $status + ids: $ids + first: $first + last: $last + after: $nextPageCursor + before: $prevPageCursor + ) { count nodes { id @@ -56,6 +69,9 @@ query getPipelineSchedulesQuery( adminPipelineSchedule } } + pageInfo { + ...PageInfo + } } } } diff --git a/app/assets/javascripts/ci/pipelines_page/components/empty_state/no_ci_empty_state.vue b/app/assets/javascripts/ci/pipelines_page/components/empty_state/no_ci_empty_state.vue index 6e7d6908cd9..728e8541ae3 100644 --- a/app/assets/javascripts/ci/pipelines_page/components/empty_state/no_ci_empty_state.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/empty_state/no_ci_empty_state.vue @@ -47,6 +47,7 @@ export default { v-else title="" :svg-path="emptyStateSvgPath" + :svg-height="null" :description="$options.i18n.noCiDescription" /> </div> diff --git a/app/assets/javascripts/ci/pipelines_page/components/nav_controls.vue b/app/assets/javascripts/ci/pipelines_page/components/nav_controls.vue index 235126fea0c..0165bbfe69d 100644 --- a/app/assets/javascripts/ci/pipelines_page/components/nav_controls.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/nav_controls.vue @@ -7,28 +7,25 @@ export default { GlButton, }, props: { - newPipelinePath: { + ciLintPath: { type: String, required: false, default: null, }, - - resetCachePath: { - type: String, + isResetCacheButtonLoading: { + type: Boolean, required: false, - default: null, + default: false, }, - - ciLintPath: { + newPipelinePath: { type: String, required: false, default: null, }, - - isResetCacheButtonLoading: { - type: Boolean, + resetCachePath: { + type: String, required: false, - default: false, + default: null, }, }, methods: { @@ -61,7 +58,6 @@ export default { category="primary" class="js-run-pipeline" data-testid="run-pipeline-button" - data-qa-selector="run_pipeline_button" > {{ s__('Pipeline|Run pipeline') }} </gl-button> diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipeline_labels.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_labels.vue index 082ede60244..8f45094eb74 100644 --- a/app/assets/javascripts/ci/pipelines_page/components/pipeline_labels.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_labels.vue @@ -17,16 +17,15 @@ export default { targetProjectFullPath: { default: '', }, + pipelineSchedulesPath: { + default: '', + }, }, props: { pipeline: { type: Object, required: true, }, - pipelineScheduleUrl: { - type: String, - required: true, - }, }, computed: { isScheduled() { @@ -38,6 +37,13 @@ export default { this.pipeline?.project?.full_path !== `/${this.targetProjectFullPath}`, ); }, + showMergedResultsBadge() { + // A merge train pipeline is technically also a merged results pipeline, + // but we want the badges to be mutually exclusive. + return ( + this.pipeline.flags.merged_result_pipeline && !this.pipeline.flags.merge_train_pipeline + ); + }, autoDevopsTagId() { return `pipeline-url-autodevops-${this.pipeline.id}`; }, @@ -52,7 +58,7 @@ export default { <gl-badge v-if="isScheduled" v-gl-tooltip - :href="pipelineScheduleUrl" + :href="pipelineSchedulesPath" target="__blank" :title="__('This pipeline was created by a schedule.')" variant="info" @@ -74,7 +80,7 @@ export default { v-gl-tooltip :title=" s__( - 'Pipeline|This pipeline ran on the contents of this merge request combined with the contents of all other merge requests queued for merging into the target branch.', + 'Pipeline|This pipeline ran on the contents of the merge request combined with the contents of all other merge requests queued for merging into the target branch.', ) " variant="info" @@ -149,7 +155,7 @@ export default { v-gl-tooltip :title=" s__( - `Pipeline|This pipeline ran on the contents of this merge request's source branch, not the target branch.`, + `Pipeline|This pipeline ran on the contents of the merge request's source branch, not the target branch.`, ) " variant="info" @@ -158,6 +164,19 @@ export default { >{{ s__('Pipeline|merge request') }}</gl-badge > <gl-badge + v-if="showMergedResultsBadge" + v-gl-tooltip + :title=" + s__( + `Pipeline|This pipeline ran on the contents of the merge request combined with the contents of the target branch.`, + ) + " + variant="info" + size="sm" + data-testid="pipeline-url-merged-results" + >{{ s__('Pipeline|merged results') }}</gl-badge + > + <gl-badge v-if="isInFork" v-gl-tooltip :title="__('Pipeline ran in fork of project')" diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipeline_operations.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_operations.vue index b05bdae65c4..8945bb06862 100644 --- a/app/assets/javascripts/ci/pipelines_page/components/pipeline_operations.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_operations.vue @@ -1,22 +1,22 @@ <script> -import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import Tracking from '~/tracking'; import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL, TRACKING_CATEGORIES } from '~/ci/constants'; -import eventHub from '../../event_hub'; import PipelineMultiActions from './pipeline_multi_actions.vue'; import PipelinesManualActions from './pipelines_manual_actions.vue'; +import PipelineStopModal from './pipeline_stop_modal.vue'; export default { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL, directives: { GlTooltip: GlTooltipDirective, - GlModalDirective, }, components: { GlButton, PipelineMultiActions, PipelinesManualActions, + PipelineStopModal, }, mixins: [Tracking.mixin()], props: { @@ -24,15 +24,12 @@ export default { type: Object, required: true, }, - cancelingPipeline: { - type: Number, - required: false, - default: null, - }, }, data() { return { + isCanceling: false, isRetrying: false, + showConfirmationModal: false, }; }, computed: { @@ -41,27 +38,36 @@ export default { this.pipeline?.details?.has_manual_actions || this.pipeline?.details?.has_scheduled_actions ); }, - isCancelling() { - return this.cancelingPipeline === this.pipeline.id; - }, }, watch: { pipeline() { - this.isRetrying = false; + if (this.isCanceling || this.isRetrying) { + this.isCanceling = false; + this.isRetrying = false; + } }, }, methods: { + onCloseModal() { + this.showConfirmationModal = false; + }, + onConfirmCancelPipeline() { + this.isCanceling = true; + this.showConfirmationModal = false; + + this.$emit('cancel-pipeline', this.pipeline); + }, handleCancelClick() { + this.showConfirmationModal = true; + this.trackClick('click_cancel_button'); - eventHub.$emit('openConfirmationModal', { - pipeline: this.pipeline, - endpoint: this.pipeline.cancel_path, - }); }, handleRetryClick() { this.isRetrying = true; + this.trackClick('click_retry_button'); - eventHub.$emit('retryPipeline', this.pipeline.retry_path); + + this.$emit('retry-pipeline', this.pipeline); }, trackClick(action) { this.track(action, { label: TRACKING_CATEGORIES.table }); @@ -72,8 +78,19 @@ export default { <template> <div class="gl-text-right"> + <pipeline-stop-modal + :pipeline="pipeline" + :show-confirmation-modal="showConfirmationModal" + @submit="onConfirmCancelPipeline" + @close-modal="onCloseModal" + /> + <div class="btn-group"> - <pipelines-manual-actions v-if="hasActions" :iid="pipeline.iid" /> + <pipelines-manual-actions + v-if="hasActions" + :iid="pipeline.iid" + @refresh-pipeline-table="$emit('refresh-pipelines-table')" + /> <gl-button v-if="pipeline.flags.retryable" @@ -83,7 +100,6 @@ export default { :disabled="isRetrying" :loading="isRetrying" class="js-pipelines-retry-button" - data-qa-selector="pipeline_retry_button" data-testid="pipelines-retry-button" icon="retry" variant="default" @@ -94,11 +110,10 @@ export default { <gl-button v-if="pipeline.flags.cancelable" v-gl-tooltip.hover - v-gl-modal-directive="'confirmation-modal'" :aria-label="$options.BUTTON_TOOLTIP_CANCEL" :title="$options.BUTTON_TOOLTIP_CANCEL" - :loading="isCancelling" - :disabled="isCancelling" + :loading="isCanceling" + :disabled="isCanceling" icon="cancel" variant="danger" category="primary" diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipelines_status_badge.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_status_badge.vue index 2da9141df8e..20e2c7e9dce 100644 --- a/app/assets/javascripts/ci/pipelines_page/components/pipelines_status_badge.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_status_badge.vue @@ -1,6 +1,5 @@ <script> import { TRACKING_CATEGORIES } from '~/ci/constants'; -import { CHILD_VIEW } from '~/ci/pipeline_details/constants'; import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import Tracking from '~/tracking'; import PipelinesTimeago from './time_ago.vue'; @@ -16,18 +15,11 @@ export default { type: Object, required: true, }, - viewType: { - type: String, - required: true, - }, }, computed: { pipelineStatus() { return this.pipeline?.details?.status ?? {}; }, - isChildView() { - return this.viewType === CHILD_VIEW; - }, }, methods: { trackClick() { @@ -39,13 +31,7 @@ export default { <template> <div> - <ci-badge-link - class="gl-mb-3" - :status="pipelineStatus" - :show-text="!isChildView" - data-qa-selector="pipeline_commit_status" - @ciStatusBadgeClick="trackClick" - /> + <ci-badge-link class="gl-mb-3" :status="pipelineStatus" @ciStatusBadgeClick="trackClick" /> <pipelines-timeago :pipeline="pipeline" /> </div> </template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipeline_stop_modal.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_stop_modal.vue index 9f38be668f2..d62a68f0dcc 100644 --- a/app/assets/javascripts/ci/pipelines_page/components/pipeline_stop_modal.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_stop_modal.vue @@ -7,7 +7,7 @@ import CiIcon from '~/vue_shared/components/ci_icon.vue'; /** * Pipeline Stop Modal. * - * Renders the modal used to confirm stopping a pipeline. + * Renders the modal used to confirm cancelling a pipeline. */ export default { components: { @@ -22,8 +22,15 @@ export default { required: true, deep: true, }, + showConfirmationModal: { + type: Boolean, + required: true, + }, }, computed: { + hasRef() { + return !isEmpty(this.pipeline.ref); + }, modalTitle() { return sprintf( s__('Pipeline|Stop pipeline #%{pipelineId}?'), @@ -34,10 +41,7 @@ export default { ); }, modalText() { - return s__(`Pipeline|You’re about to stop pipeline #%{pipelineId}.`); - }, - hasRef() { - return !isEmpty(this.pipeline.ref); + return s__(`Pipeline|You're about to stop pipeline #%{pipelineId}.`); }, primaryProps() { return { @@ -45,10 +49,13 @@ export default { attributes: { variant: 'danger' }, }; }, - cancelProps() { - return { - text: __('Cancel'), - }; + showModal: { + get() { + return this.showConfirmationModal; + }, + set() { + this.$emit('close-modal'); + }, }, }, methods: { @@ -56,14 +63,16 @@ export default { this.$emit('submit', event); }, }, + cancelProps: { text: __('Cancel') }, }; </script> <template> <gl-modal + v-model="showModal" modal-id="confirmation-modal" :title="modalTitle" :action-primary="primaryProps" - :action-cancel="cancelProps" + :action-cancel="$options.cancelProps" @primary="emitSubmit($event)" > <p> @@ -74,7 +83,7 @@ export default { </gl-sprintf> </p> - <p v-if="pipeline"> + <p> <ci-icon v-if="pipeline.details" :status="pipeline.details.status" diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipeline_url.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_url.vue index edaeb481d7b..9a49eefbf98 100644 --- a/app/assets/javascripts/ci/pipelines_page/components/pipeline_url.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_url.vue @@ -4,7 +4,7 @@ import { __ } from '~/locale'; import Tracking from '~/tracking'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import { ICONS, TRACKING_CATEGORIES } from '~/ci/constants'; +import { ICONS, PIPELINE_ID_KEY, PIPELINE_IID_KEY, TRACKING_CATEGORIES } from '~/ci/constants'; import PipelineLabels from './pipeline_labels.vue'; export default { @@ -24,13 +24,13 @@ export default { type: Object, required: true, }, - pipelineScheduleUrl: { + pipelineIdType: { type: String, - required: true, - }, - pipelineKey: { - type: String, - required: true, + required: false, + default: PIPELINE_ID_KEY, + validator(value) { + return value === PIPELINE_IID_KEY || value === PIPELINE_ID_KEY; + }, }, refClass: { type: String, @@ -173,9 +173,8 @@ export default { :href="pipeline.path" class="gl-mr-1 gl-text-blue-500!" data-testid="pipeline-url-link" - data-qa-selector="pipeline_url_link" @click="trackClick('click_pipeline_id')" - >#{{ pipeline[pipelineKey] }}</gl-link + >#{{ pipeline[pipelineIdType] }}</gl-link > <!--Commit row--> <div class="gl-display-inline-flex gl-rounded-base gl-px-2 gl-bg-gray-50 gl-text-gray-700"> @@ -237,6 +236,6 @@ export default { /> <!--End of commit row--> </div> - <pipeline-labels :pipeline-schedule-url="pipelineScheduleUrl" :pipeline="pipeline" /> + <pipeline-labels :pipeline="pipeline" /> </div> </template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipelines_manual_actions.vue b/app/assets/javascripts/ci/pipelines_page/components/pipelines_manual_actions.vue index 4dacd474bde..ebf1744aee2 100644 --- a/app/assets/javascripts/ci/pipelines_page/components/pipelines_manual_actions.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipelines_manual_actions.vue @@ -6,7 +6,6 @@ import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_m import { s__, __, sprintf } from '~/locale'; import Tracking from '~/tracking'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; -import eventHub from '../../event_hub'; import { TRACKING_CATEGORIES } from '../../constants'; import getPipelineActionsQuery from '../graphql/queries/get_pipeline_actions.query.graphql'; @@ -94,7 +93,7 @@ export default { .post(`${action.playPath}.json`) .then(() => { this.isLoading = false; - eventHub.$emit('updateTable'); + this.$emit('refresh-pipeline-table'); }) .catch(() => { this.isLoading = false; diff --git a/app/assets/javascripts/ci/pipelines_page/pipelines.vue b/app/assets/javascripts/ci/pipelines_page/pipelines.vue index 87ee5463bb0..faa013079be 100644 --- a/app/assets/javascripts/ci/pipelines_page/pipelines.vue +++ b/app/assets/javascripts/ci/pipelines_page/pipelines.vue @@ -1,5 +1,7 @@ <!-- eslint-disable vue/multi-word-component-names --> <script> +import NO_PIPELINES_SVG from '@gitlab/svgs/dist/illustrations/empty-state/empty-pipeline-md.svg?url'; +import ERROR_STATE_SVG from '@gitlab/svgs/dist/illustrations/pipelines_failed.svg?url'; import { GlEmptyState, GlIcon, GlLoadingIcon, GlCollapsibleListbox } from '@gitlab/ui'; import { isEqual } from 'lodash'; import * as Sentry from '@sentry/browser'; @@ -9,11 +11,12 @@ import { __, s__ } from '~/locale'; import Tracking from '~/tracking'; import { FILTER_TAG_IDENTIFIER, - PipelineKeyOptions, + PIPELINE_ID_KEY, + PIPELINE_IID_KEY, RAW_TEXT_WARNING, TRACKING_CATEGORIES, } from '~/ci/constants'; -import PipelinesTableComponent from '~/ci/common/pipelines_table.vue'; +import PipelinesTable from '~/ci/common/pipelines_table.vue'; import PipelinesMixin from '~/ci/pipeline_details/mixins/pipelines_mixin'; import { validateParams } from '~/ci/pipeline_details/utils'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; @@ -27,7 +30,6 @@ import NavigationControls from './components/nav_controls.vue'; import PipelinesFilteredSearch from './components/pipelines_filtered_search.vue'; export default { - PipelineKeyOptions, components: { NoCiEmptyState, GlCollapsibleListbox, @@ -37,7 +39,7 @@ export default { NavigationTabs, NavigationControls, PipelinesFilteredSearch, - PipelinesTableComponent, + PipelinesTable, TablePagination, }, mixins: [PipelinesMixin, Tracking.mixin()], @@ -46,36 +48,10 @@ export default { type: Object, required: true, }, - // Can be rendered in 3 different places, with some visual differences - // Accepts root | child - // `root` -> main view - // `child` -> rendered inside MR or Commit View - viewType: { - type: String, - required: false, - default: 'root', - }, endpoint: { type: String, required: true, }, - pipelineScheduleUrl: { - type: String, - required: false, - default: '', - }, - emptyStateSvgPath: { - type: String, - required: true, - }, - errorStateSvgPath: { - type: String, - required: true, - }, - noPipelinesSvgPath: { - type: String, - required: true, - }, hasGitlabCi: { type: Boolean, required: true, @@ -243,8 +219,9 @@ export default { }, selectedPipelineKeyOption() { return ( - this.$options.PipelineKeyOptions.find((e) => this.visibilityPipelineIdType === e.value) || - this.$options.PipelineKeyOptions[0] + this.$options.pipelineKeyOptions.find( + (option) => this.visibilityPipelineIdType === option.value, + ) || this.$options.pipelineKeyOptions[0] ); }, }, @@ -334,11 +311,12 @@ export default { }, changeVisibilityPipelineIDType(idType) { this.visibilityPipelineIdType = idType; - this.saveVisibilityPipelineIDType(idType); + + if (isLoggedIn()) { + this.saveVisibilityPipelineIDType(idType); + } }, saveVisibilityPipelineIDType(idType) { - if (!isLoggedIn()) return; - this.$apollo .mutate({ mutation: setSortPreferenceMutation, @@ -354,6 +332,20 @@ export default { }); }, }, + errorStateSvgPath: ERROR_STATE_SVG, + noPipelinesSvgPath: NO_PIPELINES_SVG, + pipelineKeyOptions: [ + { + text: __('Show Pipeline ID'), + label: __('Pipeline ID'), + value: PIPELINE_ID_KEY, + }, + { + text: __('Show Pipeline IID'), + label: __('Pipeline IID'), + value: PIPELINE_IID_KEY, + }, + ], }; </script> <template> @@ -393,9 +385,8 @@ export default { /> <gl-collapsible-listbox v-model="visibilityPipelineIdType" - data-testid="pipeline-key-collapsible-box" :toggle-text="selectedPipelineKeyOption.text" - :items="$options.PipelineKeyOptions" + :items="$options.pipelineKeyOptions" @select="changeVisibilityPipelineIDType" /> </div> @@ -411,32 +402,34 @@ export default { <no-ci-empty-state v-else-if="stateToRender === $options.stateMap.emptyState" - :empty-state-svg-path="emptyStateSvgPath" + :empty-state-svg-path="$options.noPipelinesSvgPath" :can-set-ci="canCreatePipeline" :registration-token="registrationToken" /> <gl-empty-state v-else-if="stateToRender === $options.stateMap.error" - :svg-path="errorStateSvgPath" + :svg-path="$options.errorStateSvgPath" + :svg-height="null" :title="s__('Pipelines|There was an error fetching the pipelines.')" :description="s__('Pipelines|Try again in a few moments or contact your support team.')" /> <gl-empty-state v-else-if="stateToRender === $options.stateMap.emptyTab" - :svg-path="noPipelinesSvgPath" + :svg-path="$options.noPipelinesSvgPath" :svg-height="150" :title="emptyTabMessage" /> <div v-else-if="stateToRender === $options.stateMap.tableList"> - <pipelines-table-component + <pipelines-table :pipelines="state.pipelines" - :pipeline-schedule-url="pipelineScheduleUrl" :update-graph-dropdown="updateGraphDropdown" - :view-type="viewType" - :pipeline-key-option="selectedPipelineKeyOption" + :pipeline-id-type="selectedPipelineKeyOption.value" + @cancel-pipeline="onCancelPipeline" + @refresh-pipelines-table="onRefreshPipelinesTable" + @retry-pipeline="onRetryPipeline" /> </div> diff --git a/app/assets/javascripts/ci/runner/components/registration/utils.js b/app/assets/javascripts/ci/runner/components/registration/utils.js index c8a75506c9c..c1885be9585 100644 --- a/app/assets/javascripts/ci/runner/components/registration/utils.js +++ b/app/assets/javascripts/ci/runner/components/registration/utils.js @@ -3,8 +3,8 @@ import { LINUX_PLATFORM, MACOS_PLATFORM, WINDOWS_PLATFORM, - DOWNLOAD_LOCATIONS, -} from '../../constants'; + RUNNER_PACKAGE_HOST, +} from 'jh_else_ce/ci/runner/constants'; import linuxInstall from './scripts/linux/install.sh?raw'; import osxInstall from './scripts/osx/install.sh?raw'; import windowsInstall from './scripts/windows/install.ps1?raw'; @@ -27,6 +27,47 @@ const OS = { }, }; +export const DOWNLOAD_LOCATIONS = { + [LINUX_PLATFORM]: [ + { + arch: 'amd64', + url: `https://${RUNNER_PACKAGE_HOST}/latest/binaries/gitlab-runner-linux-amd64`, + }, + { + arch: '386', + url: `https://${RUNNER_PACKAGE_HOST}/latest/binaries/gitlab-runner-linux-386`, + }, + { + arch: 'arm', + url: `https://${RUNNER_PACKAGE_HOST}/latest/binaries/gitlab-runner-linux-arm`, + }, + { + arch: 'arm64', + url: `https://${RUNNER_PACKAGE_HOST}/latest/binaries/gitlab-runner-linux-arm64`, + }, + ], + [MACOS_PLATFORM]: [ + { + arch: 'amd64', + url: `https://${RUNNER_PACKAGE_HOST}/latest/binaries/gitlab-runner-darwin-amd64`, + }, + { + arch: 'arm64', + url: `https://${RUNNER_PACKAGE_HOST}/latest/binaries/gitlab-runner-darwin-arm64`, + }, + ], + [WINDOWS_PLATFORM]: [ + { + arch: 'amd64', + url: `https://${RUNNER_PACKAGE_HOST}/latest/binaries/gitlab-runner-windows-amd64.exe`, + }, + { + arch: '386', + url: `https://${RUNNER_PACKAGE_HOST}/latest/binaries/gitlab-runner-windows-386.exe`, + }, + ], +}; + export const commandPrompt = ({ platform }) => { return (OS[platform] || OS[DEFAULT_PLATFORM]).commandPrompt; }; diff --git a/app/assets/javascripts/ci/runner/components/runner_details.vue b/app/assets/javascripts/ci/runner/components/runner_details.vue index fac90fb0370..0ec2ef30c20 100644 --- a/app/assets/javascripts/ci/runner/components/runner_details.vue +++ b/app/assets/javascripts/ci/runner/components/runner_details.vue @@ -29,10 +29,6 @@ export default { import('ee_component/ci/runner/components/runner_maintenance_note_detail.vue'), RunnerGroups, RunnerProjects, - RunnerUpgradeStatusBadge: () => - import('ee_component/ci/runner/components/runner_upgrade_status_badge.vue'), - RunnerUpgradeStatusAlert: () => - import('ee_component/ci/runner/components/runner_upgrade_status_alert.vue'), RunnerTags, RunnerManagersDetail, TimeAgo, @@ -92,7 +88,6 @@ export default { <template> <div> - <runner-upgrade-status-alert class="gl-my-4" :runner="runner" /> <div class="gl-pt-4"> <dl class="gl-mb-0 gl-display-grid runner-details-grid-template"> <runner-detail :label="s__('Runners|Description')" :value="runner.description" /> @@ -104,16 +99,6 @@ export default { <time-ago :time="runner.contactedAt" /> </template> </runner-detail> - <runner-detail :label="s__('Runners|Version')"> - <template v-if="runner.version" #value> - {{ runner.version }} - <runner-upgrade-status-badge size="sm" :runner="runner" /> - </template> - </runner-detail> - <runner-detail :label="s__('Runners|IP Address')" :value="runner.ipAddress" /> - <runner-detail :label="s__('Runners|Executor')" :value="runner.executorName" /> - <runner-detail :label="s__('Runners|Architecture')" :value="runner.architectureName" /> - <runner-detail :label="s__('Runners|Platform')" :value="runner.platformName" /> <runner-detail :label="s__('Runners|Configuration')"> <template v-if="configTextProtected || configTextUntagged" #value> <gl-intersperse> diff --git a/app/assets/javascripts/ci/runner/components/runner_form_fields.vue b/app/assets/javascripts/ci/runner/components/runner_form_fields.vue index 38e36733045..b8c80986fbc 100644 --- a/app/assets/javascripts/ci/runner/components/runner_form_fields.vue +++ b/app/assets/javascripts/ci/runner/components/runner_form_fields.vue @@ -92,9 +92,7 @@ export default { <gl-form-group :label="__('Tags')" label-for="runner-tags"> <template #description> <gl-sprintf - :message=" - s__('Runners|Multiple tags must be separated by a comma. For example, %{example}.') - " + :message="s__('Runners|Separate multiple tags with a comma. For example, %{example}.')" > <template #example> <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> @@ -106,7 +104,7 @@ export default { <gl-sprintf :message=" s__( - 'Runners|Add tags for the types of jobs the runner processes to ensure that the runner only runs jobs that you intend it to. %{helpLinkStart}Learn more.%{helpLinkEnd}', + 'Runners|Add tags to specify jobs that the runner can run. %{helpLinkStart}Learn more.%{helpLinkEnd}', ) " > @@ -191,7 +189,9 @@ export default { ) " label-for="runner-max-timeout" - :description="s__('Runners|Enter the number of seconds.')" + :description=" + s__('Runners|Enter the job timeout in seconds. Must be a minimum of 600 seconds.') + " > <gl-form-input id="runner-max-timeout" diff --git a/app/assets/javascripts/ci/runner/components/runner_header.vue b/app/assets/javascripts/ci/runner/components/runner_header.vue index 55a33ef2074..0fa06537ed6 100644 --- a/app/assets/javascripts/ci/runner/components/runner_header.vue +++ b/app/assets/javascripts/ci/runner/components/runner_header.vue @@ -13,6 +13,8 @@ export default { TimeAgo, RunnerTypeBadge, RunnerStatusBadge, + RunnerUpgradeStatusBadge: () => + import('ee_component/ci/runner/components/runner_upgrade_status_badge.vue'), }, directives: { GlTooltip: GlTooltipDirective, @@ -40,6 +42,7 @@ export default { <div class="gl-display-flex gl-align-items-flex-start gl-gap-3 gl-flex-wrap gl-mt-3"> <runner-status-badge :contacted-at="runner.contactedAt" :status="runner.status" /> <runner-type-badge :type="runner.runnerType" /> + <runner-upgrade-status-badge :runner="runner" /> <span v-if="runner.createdAt"> <gl-sprintf :message="__('%{locked} created %{timeago}')"> <template #locked> diff --git a/app/assets/javascripts/ci/runner/components/runner_type_icon.vue b/app/assets/javascripts/ci/runner/components/runner_type_icon.vue new file mode 100644 index 00000000000..c56f28e10a3 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_type_icon.vue @@ -0,0 +1,62 @@ +<script> +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + I18N_INSTANCE_TYPE, + I18N_INSTANCE_RUNNER_DESCRIPTION, + I18N_GROUP_TYPE, + I18N_GROUP_RUNNER_DESCRIPTION, + I18N_PROJECT_TYPE, + I18N_PROJECT_RUNNER_DESCRIPTION, +} from '../constants'; + +const ICON_DATA = { + [INSTANCE_TYPE]: { + name: 'users', + tooltip: `${I18N_INSTANCE_TYPE}: ${I18N_INSTANCE_RUNNER_DESCRIPTION}`, + }, + [GROUP_TYPE]: { + name: 'group', + tooltip: `${I18N_GROUP_TYPE}: ${I18N_GROUP_RUNNER_DESCRIPTION}`, + }, + [PROJECT_TYPE]: { + name: 'project', + tooltip: `${I18N_PROJECT_TYPE}: ${I18N_PROJECT_RUNNER_DESCRIPTION}`, + }, +}; + +export default { + components: { + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + type: { + type: String, + required: false, + default: null, + validator(type) { + return Boolean(ICON_DATA[type]); + }, + }, + }, + computed: { + icon() { + return ICON_DATA[this.type]; + }, + }, +}; +</script> +<template> + <gl-icon + v-if="icon" + v-gl-tooltip="icon.tooltip" + :aria-label="icon.tooltip" + :name="icon.name" + v-bind="$attrs" + /> +</template> diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js index 3293c68ddb8..b3cc295f8e4 100644 --- a/app/assets/javascripts/ci/runner/constants.js +++ b/app/assets/javascripts/ci/runner/constants.js @@ -216,54 +216,8 @@ export const LINUX_PLATFORM = 'linux'; export const MACOS_PLATFORM = 'osx'; export const WINDOWS_PLATFORM = 'windows'; -export const DOWNLOAD_LOCATIONS = { - [LINUX_PLATFORM]: [ - { - arch: 'amd64', - url: - 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64', - }, - { - arch: '386', - url: - 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-386', - }, - { - arch: 'arm', - url: - 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-arm', - }, - { - arch: 'arm64', - url: - 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-arm64', - }, - ], - [MACOS_PLATFORM]: [ - { - arch: 'amd64', - url: - 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-darwin-amd64', - }, - { - arch: 'arm64', - url: - 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-darwin-arm64', - }, - ], - [WINDOWS_PLATFORM]: [ - { - arch: 'amd64', - url: - 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-windows-amd64.exe', - }, - { - arch: '386', - url: - 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-windows-386.exe', - }, - ], -}; +// About Gitlab Runner Package host +export const RUNNER_PACKAGE_HOST = 'gitlab-runner-downloads.s3.amazonaws.com'; export const DEFAULT_PLATFORM = LINUX_PLATFORM; diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql index 1a2ad59650e..e2c890b3834 100644 --- a/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql +++ b/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql @@ -6,10 +6,6 @@ fragment RunnerDetailsShared on CiRunner { accessLevel runUntagged locked - ipAddress - executorName - architectureName - platformName description maximumTimeout jobCount diff --git a/app/assets/javascripts/ci/runner/sentry_utils.js b/app/assets/javascripts/ci/runner/sentry_utils.js index 29de1f9adae..25fecdcfa7d 100644 --- a/app/assets/javascripts/ci/runner/sentry_utils.js +++ b/app/assets/javascripts/ci/runner/sentry_utils.js @@ -6,15 +6,16 @@ const COMPONENT_TAG = 'vue_component'; * Captures an error in a Vue component and sends it * to Sentry * - * @param {Object} options - * @param {Error} options.error - Exception or error - * @param {String} options.component - Component name in CamelCase format + * @param {Object} options Exception details + * @param {Object} options.error An exception-like object + * @param {string} [options.component=] Component name in CamelCase format */ export const captureException = ({ error, component }) => { - Sentry.withScope((scope) => { - if (component) { - scope.setTag(COMPONENT_TAG, component); - } + if (component) { + Sentry.captureException(error, { + tags: { [COMPONENT_TAG]: component }, + }); + } else { Sentry.captureException(error); - }); + } }; diff --git a/app/assets/javascripts/ci/utils.js b/app/assets/javascripts/ci/utils.js index eb9e9538b75..8a4f28404c6 100644 --- a/app/assets/javascripts/ci/utils.js +++ b/app/assets/javascripts/ci/utils.js @@ -1,17 +1,9 @@ import * as Sentry from '@sentry/browser'; export const reportToSentry = (component, failureType) => { - Sentry.withScope((scope) => { - scope.setTag('component', component); - Sentry.captureException(failureType); - }); -}; - -export const reportMessageToSentry = (component, message, context) => { - Sentry.withScope((scope) => { - // eslint-disable-next-line @gitlab/require-i18n-strings - scope.setContext('Vue data', context); - scope.setTag('component', component); - Sentry.captureMessage(message); + Sentry.captureException(failureType, { + tags: { + component, + }, }); }; diff --git a/app/assets/javascripts/clusters_list/components/agent_empty_state.vue b/app/assets/javascripts/clusters_list/components/agent_empty_state.vue index 2f45ef8a862..4a20f9ec10d 100644 --- a/app/assets/javascripts/clusters_list/components/agent_empty_state.vue +++ b/app/assets/javascripts/clusters_list/components/agent_empty_state.vue @@ -16,7 +16,11 @@ export default { </script> <template> - <gl-empty-state :svg-path="emptyStateImage" :svg-height="100"> + <gl-empty-state + :svg-path="emptyStateImage" + :svg-height="100" + data-testid="cluster-agent-empty-state" + > <template #title> <gl-sprintf :message="$options.i18n.introText"> <template #link="{ content }"> diff --git a/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue b/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue index f4134ab5072..339ea3b7c0d 100644 --- a/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue +++ b/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue @@ -22,7 +22,11 @@ export default { <template> <div> - <gl-empty-state :svg-path="clustersEmptyStateImage" :svg-height="100"> + <gl-empty-state + :svg-path="clustersEmptyStateImage" + :svg-height="100" + data-testid="clusters-empty-state" + > <template #title> <p> <gl-sprintf :message="$options.i18n.introText"> diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js index 1ea18dcc97d..4537fd51fcf 100644 --- a/app/assets/javascripts/clusters_list/store/actions.js +++ b/app/assets/javascripts/clusters_list/store/actions.js @@ -17,9 +17,10 @@ const allNodesPresent = (clusters, retryCount) => { }; export const reportSentryError = (_store, { error, tag }) => { - Sentry.withScope((scope) => { - scope.setTag('javascript_clusters_list', tag); - Sentry.captureException(error); + Sentry.captureException(error, { + tags: { + javascript_clusters_list: tag, + }, }); }; diff --git a/app/assets/javascripts/comment_templates/components/form.vue b/app/assets/javascripts/comment_templates/components/form.vue index c29482eab7a..5a5d221591a 100644 --- a/app/assets/javascripts/comment_templates/components/form.vue +++ b/app/assets/javascripts/comment_templates/components/form.vue @@ -93,7 +93,7 @@ export default { this.$emit('saved'); this.updateCommentTemplate = { name: '', content: '' }; this.showValidation = false; - this.track_event('i_code_review_saved_replies_create'); + this.trackEvent('i_code_review_saved_replies_create'); } }, }) diff --git a/app/assets/javascripts/commit/pipelines/legacy_pipelines_table_wrapper.vue b/app/assets/javascripts/commit/pipelines/legacy_pipelines_table_wrapper.vue index 5e84dcbe48e..1954f9f8f35 100644 --- a/app/assets/javascripts/commit/pipelines/legacy_pipelines_table_wrapper.vue +++ b/app/assets/javascripts/commit/pipelines/legacy_pipelines_table_wrapper.vue @@ -2,8 +2,8 @@ import { GlButton, GlEmptyState, GlLoadingIcon, GlModal, GlLink, GlSprintf } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { getParameterByName } from '~/lib/utils/url_utility'; -import PipelinesTableComponent from '~/ci/common/pipelines_table.vue'; -import { PipelineKeyOptions } from '~/ci/constants'; +import PipelinesTable from '~/ci/common/pipelines_table.vue'; +import { PIPELINE_ID_KEY } from '~/ci/constants'; import eventHub from '~/ci/event_hub'; import PipelinesMixin from '~/ci/pipeline_details/mixins/pipelines_mixin'; import PipelinesService from '~/ci/pipelines_page/services/pipelines_service'; @@ -13,7 +13,6 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { s__, __ } from '~/locale'; export default { - PipelineKeyOptions, components: { GlButton, GlEmptyState, @@ -21,7 +20,7 @@ export default { GlLoadingIcon, GlModal, GlSprintf, - PipelinesTableComponent, + PipelinesTable, TablePagination, }, mixins: [PipelinesMixin, glFeatureFlagMixin()], @@ -180,6 +179,7 @@ export default { } }, }, + pipelineIdKey: PIPELINE_ID_KEY, modal: { actionPrimary: { text: s__('Pipeline|Run pipeline'), @@ -225,6 +225,7 @@ export default { <gl-empty-state v-else-if="shouldRenderErrorState" :svg-path="errorStateSvgPath" + :svg-height="null" :title=" s__(`Pipelines|There was an error fetching the pipelines. Try again in a few moments or contact your support team.`) @@ -279,11 +280,14 @@ export default { {{ $options.i18n.runPipelineText }} </gl-button> - <pipelines-table-component + <pipelines-table :pipelines="state.pipelines" :update-graph-dropdown="updateGraphDropdown" :view-type="viewType" - :pipeline-key-option="$options.PipelineKeyOptions[0]" + :pipeline-id-type="$options.pipelineIdKey" + @cancel-pipeline="onCancelPipeline" + @refresh-pipelines-table="onRefreshPipelinesTable" + @retry-pipeline="onRetryPipeline" > <template #table-header-actions> <div v-if="canRenderPipelineButton" class="gl-text-right"> @@ -296,7 +300,7 @@ export default { </gl-button> </div> </template> - </pipelines-table-component> + </pipelines-table> </div> <gl-modal diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js index beeb9b9ada4..6ca59f634a2 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js @@ -11,7 +11,7 @@ const apolloProvider = new VueApollo({ /** * Used in: - * - Project Pipelines List (projects:pipelines:index) + * - Project Pipelines List (projects:pipelines) * - Commit details View > Pipelines Tab > Pipelines Table (projects:commit:pipelines) * - Merge request details View > Pipelines Tab > Pipelines Table (projects:merge_requests:show) * - New merge request View > Pipelines Tab > Pipelines Table (projects:merge_requests:creations:new) diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 25c03496a76..2e9388c1e20 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -4,6 +4,8 @@ import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2'; import { __ } from '~/locale'; import { VARIANT_DANGER } from '~/alert'; import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue'; +import { CONTENT_EDITOR_READY_EVENT } from '~/vue_shared/constants'; +import markdownEditorEventHub from '~/vue_shared/components/markdown/eventhub'; import { createContentEditor } from '../services/create_content_editor'; import { ALERT_EVENT, TIPTAP_AUTOFOCUS_OPTIONS } from '../constants'; import ContentEditorAlert from './content_editor_alert.vue'; @@ -161,9 +163,10 @@ export default { }, }); }, - mounted() { + async mounted() { this.$emit('initialized'); - this.setSerializedContent(this.markdown); + await this.setSerializedContent(this.markdown); + markdownEditorEventHub.$emit(CONTENT_EDITOR_READY_EVENT); }, beforeDestroy() { this.contentEditor.dispose(); @@ -238,11 +241,7 @@ export default { @keydown="$emit('keydown', $event)" /> <content-editor-alert /> - <div - data-testid="content-editor" - data-qa-selector="content_editor_container" - :class="{ 'is-focused': focused }" - > + <div data-testid="content-editor" :class="{ 'is-focused': focused }"> <formatting-toolbar ref="toolbar" :supports-quick-actions="supportsQuickActions" @@ -275,7 +274,8 @@ export default { target="_blank" category="tertiary" size="small" - title="Markdown is supported" + :title="__('Markdown is supported')" + :aria-label="__('Markdown is supported')" class="gl-px-3!" /> </div> diff --git a/app/assets/javascripts/content_editor/components/content_editor_provider.vue b/app/assets/javascripts/content_editor/components/content_editor_provider.vue index fa842f23cc3..955fa129ce7 100644 --- a/app/assets/javascripts/content_editor/components/content_editor_provider.vue +++ b/app/assets/javascripts/content_editor/components/content_editor_provider.vue @@ -1,13 +1,7 @@ <script> export default { provide() { - // We can't use this.contentEditor due to bug in vue-apollo when - // provide is called in beforeCreate - // See https://github.com/vuejs/vue-apollo/pull/1153 for details - - // @vue-compat does not care to normalize propsData fields - const contentEditor = - this.$options.propsData.contentEditor || this.$options.propsData['content-editor']; + const { contentEditor } = this; return { contentEditor, diff --git a/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue index 4cf150dd948..78a01693f14 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue @@ -58,7 +58,7 @@ export default { name="content_editor_image" class="gl-display-none" :aria-label="$options.i18n.inputLabel" - data-qa-selector="file_upload_field" + data-testid="file-upload-field" @change="onFileSelect" /> </span> diff --git a/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue index bd30bdcea0c..4b1e14665de 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue @@ -75,7 +75,7 @@ export default { :selected="activeItemLabel" :disabled="!activeItem" :data-qa-text-style="activeItemLabel" - data-qa-selector="text_style_dropdown" + data-testid="text-style-dropdown" size="small" toggle-class="btn-default-tertiary" @select="execute" diff --git a/app/assets/javascripts/content_editor/extensions/selection.js b/app/assets/javascripts/content_editor/extensions/selection.js index 2e0bb29e5a1..0c24207b395 100644 --- a/app/assets/javascripts/content_editor/extensions/selection.js +++ b/app/assets/javascripts/content_editor/extensions/selection.js @@ -6,12 +6,22 @@ export default Extension.create({ name: 'selection', addProseMirrorPlugins() { + let contextMenuVisible = false; + return [ new Plugin({ key: new PluginKey('selection'), props: { + handleDOMEvents: { + contextmenu() { + contextMenuVisible = true; + setTimeout(() => { + contextMenuVisible = false; + }); + }, + }, decorations(state) { - if (state.selection.empty) return null; + if (state.selection.empty || contextMenuVisible) return null; return DecorationSet.create(state.doc, [ Decoration.inline(state.selection.from, state.selection.to, { diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 17e650644b3..0897232cf89 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -561,7 +561,14 @@ const linkType = (sourceMarkdown) => { return LINK_HTML; }; -const normalizeUrl = (url) => decodeURIComponent(removeLastSlashInUrlPath(removeUrlProtocol(url))); +const normalizeUrl = (url) => { + const processedUrl = removeLastSlashInUrlPath(removeUrlProtocol(url)); + try { + return decodeURIComponent(processedUrl); + } catch { + return processedUrl; + } +}; /** * Validates that the provided URL is a valid GFM autolink diff --git a/app/assets/javascripts/crm/organizations/components/graphql/create_organization.mutation.graphql b/app/assets/javascripts/crm/organizations/components/graphql/create_customer_relations_organization.mutation.graphql index 2cc7e53ee9b..8e019420eb7 100644 --- a/app/assets/javascripts/crm/organizations/components/graphql/create_organization.mutation.graphql +++ b/app/assets/javascripts/crm/organizations/components/graphql/create_customer_relations_organization.mutation.graphql @@ -1,6 +1,6 @@ #import "./crm_organization_fields.fragment.graphql" -mutation createOrganization($input: CustomerRelationsOrganizationCreateInput!) { +mutation createCustomerRelationsOrganization($input: CustomerRelationsOrganizationCreateInput!) { customerRelationsOrganizationCreate(input: $input) { organization { ...OrganizationFragment 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 4d2a038458d..fb056e4fa2c 100644 --- a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue +++ b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue @@ -4,7 +4,7 @@ import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPENAME_CRM_ORGANIZATION, TYPENAME_GROUP } from '~/graphql_shared/constants'; 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 createCustomerRelationsOrganizationMutation from './graphql/create_customer_relations_organization.mutation.graphql'; import updateOrganizationMutation from './graphql/update_organization.mutation.graphql'; export default { @@ -31,7 +31,7 @@ export default { mutation() { if (this.isEditMode) return updateOrganizationMutation; - return createOrganizationMutation; + return createCustomerRelationsOrganizationMutation; }, getQuery() { return { diff --git a/app/assets/javascripts/custom_emoji/components/app.vue b/app/assets/javascripts/custom_emoji/components/app.vue index 405a296397f..00b904fbea4 100644 --- a/app/assets/javascripts/custom_emoji/components/app.vue +++ b/app/assets/javascripts/custom_emoji/components/app.vue @@ -8,7 +8,7 @@ export default {}; <h4 class="gl-mt-0"> {{ __('Custom emoji') }} </h4> - <p>{{ __('Custom emoji will be available to use in every project in group.') }}</p> + <p>{{ __('Custom emoji will be available to use in every project in the group.') }}</p> <router-view /> </div> </div> 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 ccf4b064fa4..f21086185fb 100644 --- a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue +++ b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue @@ -283,7 +283,7 @@ export default { </template> </gl-sprintf> </template> - <gl-form-input id="deploy_token_username" v-model="username" class="gl-form-input-xl" /> + <gl-form-input id="deploy_token_username" v-model="username" width="xl" /> </gl-form-group> <gl-form-group :label="$options.translations.addTokenScopesLabel" diff --git a/app/assets/javascripts/design_management/components/design_description/description_form.vue b/app/assets/javascripts/design_management/components/design_description/description_form.vue index 413442074f0..6be643e88dc 100644 --- a/app/assets/javascripts/design_management/components/design_description/description_form.vue +++ b/app/assets/javascripts/design_management/components/design_description/description_form.vue @@ -4,7 +4,6 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import { __, s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; -import glFeaturesFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { toggleMarkCheckboxes } from '~/behaviors/markdown/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -36,7 +35,6 @@ export default { placeholder: s__('DesignManagement|Write a comment or drag your files here…'), 'aria-label': s__('DesignManagement|Design description'), }, - mixins: [glFeaturesFlagMixin()], markdownDocsPath: helpPagePath('user/markdown'), quickActionsDocsPath: helpPagePath('user/project/quick_actions'), props: { @@ -174,7 +172,6 @@ export default { :render-markdown-path="markdownPreviewPath" :markdown-docs-path="$options.markdownDocsPath" :form-field-props="$options.formFieldProps" - :enable-content-editor="Boolean(glFeatures.contentEditorOnIssues)" :quick-actions-docs-path="$options.quickActionsDocsPath" :autosave-key="autosaveKey" enable-autocomplete diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 08306312c2e..924c515ee2d 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -3,6 +3,7 @@ import { GlLoadingIcon, GlPagination, GlSprintf, GlAlert } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; // eslint-disable-next-line no-restricted-imports import { mapState, mapGetters, mapActions } from 'vuex'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import api from '~/api'; import { @@ -22,6 +23,7 @@ import { __ } from '~/locale'; import notesEventHub from '~/notes/event_hub'; import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller'; +import { sortFindingsByFile } from '../utils/sort_findings_by_file'; import { MR_TREE_SHOW_KEY, ALERT_OVERFLOW_HIDDEN, @@ -42,7 +44,7 @@ import { import diffsEventHub from '../event_hub'; import { reviewStatuses } from '../utils/file_reviews'; import { diffsApp } from '../utils/performance'; -import { updateChangesTabCount } from '../utils/merge_request'; +import { updateChangesTabCount, extractFileHash } from '../utils/merge_request'; import { queueRedisHllEvents } from '../utils/queue_events'; import FindingsDrawer from './shared/findings_drawer.vue'; import CollapsedFilesWarning from './collapsed_files_warning.vue'; @@ -53,6 +55,7 @@ import HiddenFilesWarning from './hidden_files_warning.vue'; import NoChanges from './no_changes.vue'; import VirtualScrollerScrollSync from './virtual_scroller_scroll_sync'; import DiffsFileTree from './diffs_file_tree.vue'; +import getMRCodequalityReports from './graphql/get_mr_codequality_reports.query.graphql'; export default { name: 'DiffsApp', @@ -75,6 +78,7 @@ export default { GenerateTestFileDrawer: () => import('ee_component/ai/components/generate_test_file_drawer.vue'), }, + mixins: [glFeatureFlagsMixin()], alerts: { ALERT_OVERFLOW_HIDDEN, ALERT_MERGE_CONFLICT, @@ -86,6 +90,16 @@ export default { required: false, default: '', }, + projectPath: { + type: String, + required: false, + default: '', + }, + iid: { + type: String, + required: false, + default: '', + }, endpointSast: { type: String, required: false, @@ -123,6 +137,32 @@ export default { subscribedToVirtualScrollingEvents: false, }; }, + apollo: { + getMRCodequalityReports: { + query: getMRCodequalityReports, + variables() { + return { fullPath: this.projectPath, iid: this.iid }; + }, + skip() { + return !this.endpointCodequality || !this.sastReportsInInlineDiff; + }, + update(data) { + if (data?.project?.mergeRequest?.codequalityReportsComparer?.report?.newErrors) { + this.$store.commit( + 'diffs/SET_CODEQUALITY_DATA', + sortFindingsByFile( + data.project.mergeRequest.codequalityReportsComparer.report.newErrors, + ), + ); + } + }, + error() { + createAlert({ + message: __('Something went wrong fetching the CodeQuality Findings. Please try again!'), + }); + }, + }, + }, computed: { ...mapState('diffs', { numTotalFiles: 'realSize', @@ -220,6 +260,9 @@ export default { resourceId() { return convertToGraphQLId('MergeRequest', this.getNoteableData.id); }, + sastReportsInInlineDiff() { + return this.glFeatures.sastReportsInInlineDiff; + }, }, watch: { commit(newCommit, oldCommit) { @@ -344,12 +387,13 @@ export default { ...mapActions(['startTaskList']), ...mapActions('diffs', [ 'moveToNeighboringCommit', - 'setBaseConfig', 'setCodequalityEndpoint', 'setSastEndpoint', 'fetchDiffFilesMeta', 'fetchDiffFilesBatch', 'fetchFileByFile', + 'loadCollapsedDiff', + 'setFileForcedOpen', 'fetchCoverageFiles', 'fetchCodequality', 'fetchSast', @@ -373,15 +417,34 @@ export default { notesEventHub.$on('refetchDiffData', this.refetchDiffData); notesEventHub.$on('fetchedNotesData', this.rereadNoteHash); diffsEventHub.$on('diffFilesModified', this.setDiscussions); + diffsEventHub.$on('doneLoadingBatches', this.autoScroll); diffsEventHub.$on(EVT_MR_PREPARED, this.fetchData); }, unsubscribeFromEvents() { diffsEventHub.$off(EVT_MR_PREPARED, this.fetchData); + diffsEventHub.$off('doneLoadingBatches', this.autoScroll); diffsEventHub.$off('diffFilesModified', this.setDiscussions); notesEventHub.$off('fetchedNotesData', this.rereadNoteHash); notesEventHub.$off('refetchDiffData', this.refetchDiffData); notesEventHub.$off('fetchDiffData', this.fetchData); }, + autoScroll() { + const lineCode = window.location.hash; + const sha1InHash = extractFileHash({ input: lineCode }); + + if (sha1InHash) { + const idx = this.diffs.findIndex((diffFile) => diffFile.file_hash === sha1InHash); + const file = this.diffs[idx]; + + this.loadCollapsedDiff({ file }) + .then(() => { + this.setDiscussions(); + this.scrollVirtualScrollerToIndex(idx); + this.setFileForcedOpen({ filePath: file.new_path }); + }) + .catch(() => {}); + } + }, navigateToDiffFileNumber(number) { this.navigateToDiffFileIndex(number - 1); }, @@ -445,7 +508,7 @@ export default { this.fetchCoverageFiles(); } - if (this.endpointCodequality) { + if (this.endpointCodequality && !this.sastReportsInInlineDiff) { this.fetchCodequality(); } @@ -623,9 +686,13 @@ export default { page-mode > <template #default="{ item, index, active }"> - <dynamic-scroller-item :item="item" :active="active" :class="{ active }"> + <dynamic-scroller-item + v-if="active" + :item="item" + :active="active" + :class="{ active }" + > <diff-file - v-if="active" :file="item" :reviewed="fileReviews[item.id]" :is-first-file="index === 0" diff --git a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue index 4501988ee4f..74b872d8bc4 100644 --- a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue +++ b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue @@ -26,7 +26,7 @@ export default { <template> <gl-dropdown :text="selectedVersionName" - data-qa-selector="dropdown_content" + data-testid="version-dropdown-content" size="small" category="tertiary" > diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index bc2376fec09..13388307955 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -90,7 +90,7 @@ export default { variant="default" icon="file-tree" class="gl-mr-3 js-toggle-tree-list btn-icon" - data-qa-selector="file_tree_button" + data-testid="file-tree-button" :title="toggleFileBrowserTitle" :aria-label="toggleFileBrowserTitle" :selected="showTreeList" @@ -141,7 +141,7 @@ export default { <compare-dropdown-layout :versions="diffCompareDropdownTargetVersions" class="mr-version-compare-dropdown" - data-qa-selector="target_version_dropdown" + data-testid="target-version-dropdown" /> </template> <template #source> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index f99edced361..c74a4b47fcb 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -161,6 +161,9 @@ export default { manuallyCollapsed() { return collapsedType(this.file) === DIFF_FILE_MANUAL_COLLAPSE; }, + forcedOpen() { + return this.file.viewer.forceOpen; + }, showBody() { return !this.isCollapsed || this.automaticallyCollapsed; }, @@ -174,6 +177,10 @@ export default { return Boolean(gon.current_user_id); }, isCollapsed() { + if (this.forcedOpen) { + return false; + } + if (collapsedType(this.file) !== DIFF_FILE_MANUAL_COLLAPSE) { return this.viewDiffsFileByFile ? false : this.file.viewer?.automaticallyCollapsed; } @@ -201,6 +208,11 @@ export default { this.manageViewedEffects(); }, }, + 'file.viewer.forceOpen': { + handler: function fileForcedOpenHandler() { + this.handleToggle(); + }, + }, 'file.file_hash': { handler: function hashChangeWatch(newHash, oldHash) { if ( @@ -390,23 +402,23 @@ export default { <div v-if="idState.forkMessageVisible" - class="js-file-fork-suggestion-section file-fork-suggestion" + class="js-file-fork-suggestion-section file-fork-suggestion gl-border-1 gl-border-solid gl-border-gray-100 gl-border-top-0" > <span v-safe-html="forkMessage" class="file-fork-suggestion-note"></span> <gl-button :href="file.fork_path" - class="js-fork-suggestion-button" + class="js-fork-suggestion-button gl-mr-3" category="secondary" variant="confirm" >{{ $options.i18n.fork }}</gl-button > - <button - class="js-cancel-fork-suggestion-button btn btn-grouped" - type="button" + <gl-button + class="js-cancel-fork-suggestion-button" + category="secondary" @click="hideForkMessage" > {{ $options.i18n.cancel }} - </button> + </gl-button> </div> <template v-else> <div diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index a9e63ad53bb..20f82500a02 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -232,10 +232,15 @@ export default { 'setCurrentFileHash', 'reviewFile', 'setFileCollapsedByUser', + 'setFileForcedOpen', 'setGenerateTestFilePath', 'toggleFileCommentForm', ]), handleToggleFile() { + this.setFileForcedOpen({ + filePath: this.diffFile.file_path, + forced: false, + }); this.$emit('toggleFile'); }, showForkMessage(e) { @@ -278,6 +283,10 @@ export default { } if ((open && reviewed) || (closed && !reviewed)) { + this.setFileForcedOpen({ + filePath: this.diffFile.file_path, + forced: false, + }); this.$emit('toggleFile'); } }, @@ -293,7 +302,7 @@ export default { 'is-sidebar-moved': glFeatures.movedMrSidebar, }" class="js-file-title file-title file-title-flex-parent gl-border" - data-qa-selector="file_title_container" + data-testid="file-title-container" :data-qa-file-name="filePath" @click.self="handleToggleFile" > @@ -423,7 +432,7 @@ export default { right toggle-class="btn-icon js-diff-more-actions" class="gl-pt-0!" - data-qa-selector="dropdown_button" + data-testid="options-dropdown-button" lazy @show="setMoreActionsShown(true)" @hidden="setMoreActionsShown(false)" @@ -450,7 +459,7 @@ export default { ref="ideEditButton" :href="diffFile.ide_edit_path" class="js-ide-edit-blob" - data-qa-selector="edit_in_ide_button" + data-testid="edit-in-ide-button" target="_blank" > {{ __('Open in Web IDE') }} @@ -482,7 +491,7 @@ export default { <gl-dropdown-item v-if="diffHasDiscussions(diffFile)" ref="toggleDiscussionsButton" - data-qa-selector="toggle_comments_button" + data-testid="toggle-comments-button" @click="toggleFileDiscussionWrappers(diffFile)" > <template v-if="diffHasExpandedDiscussions(diffFile)"> diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue index ee6e9a2fc94..3dad7a1a8e4 100644 --- a/app/assets/javascripts/diffs/components/diff_row.vue +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -248,7 +248,6 @@ export default { :class="$options.classNameMapCellLeft(props)" data-testid="left-line-number" class="diff-td diff-line-num" - data-qa-selector="new_diff_line_link" > <span v-if=" @@ -266,7 +265,6 @@ export default { :draggable="!props.line.left.commentsDisabled" type="button" class="add-diff-note unified-diff-components-diff-note-button note-button js-add-diff-note-button" - data-qa-selector="diff_comment_button" :disabled="props.line.left.commentsDisabled" :aria-disabled="props.line.left.commentsDisabled" @click=" diff --git a/app/assets/javascripts/diffs/components/graphql/get_mr_codequality_reports.query.graphql b/app/assets/javascripts/diffs/components/graphql/get_mr_codequality_reports.query.graphql new file mode 100644 index 00000000000..b6920d0f6ec --- /dev/null +++ b/app/assets/javascripts/diffs/components/graphql/get_mr_codequality_reports.query.graphql @@ -0,0 +1,46 @@ +query getMRCodequalityReports($fullPath: ID!, $iid: String!) { + project(fullPath: $fullPath) { + id + mergeRequest(iid: $iid) { + id + title + codequalityReportsComparer { + report { + status + newErrors { + description + fingerprint + severity + filePath + line + webUrl + engineName + } + resolvedErrors { + description + fingerprint + severity + filePath + line + webUrl + engineName + } + existingErrors { + description + fingerprint + severity + filePath + line + webUrl + engineName + } + summary { + errored + resolved + total + } + } + } + } + } +} diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index 7a661d51c9b..f4715c591b2 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -164,11 +164,7 @@ export default { </script> <template> - <div - ref="wrapper" - class="tree-list-holder d-flex flex-column" - data-qa-selector="file_tree_container" - > + <div ref="wrapper" class="tree-list-holder d-flex flex-column" data-testid="file-tree-container"> <div class="gl-pb-3 position-relative tree-list-search d-flex"> <div class="flex-fill d-flex"> <gl-icon name="search" class="gl-absolute gl-top-3 gl-left-3 tree-list-icon" /> @@ -181,7 +177,6 @@ export default { name="diff-tree-search" class="form-control" data-testid="diff-tree-search" - data-qa-selector="diff_tree_search" /> <button v-show="search" diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 49f25416585..c0b6c8159dc 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; // eslint-disable-next-line no-restricted-imports import { mapActions, mapState, mapGetters } from 'vuex'; +import { cleanLeadingSeparator } from '~/lib/utils/url_utility'; import { apolloProvider } from '~/graphql_shared/issuable_client'; import { getCookie, parseBoolean, removeCookie } from '~/lib/utils/common_utils'; import notesStore from '~/mr_notes/stores'; @@ -31,6 +32,8 @@ export default function initDiffsApp(store = notesStore) { }, data() { return { + projectPath: dataset.projectPath || '', + iid: dataset.iid || '', endpointCoverage: dataset.endpointCoverage || '', endpointCodequality: dataset.endpointCodequality || '', endpointSast: dataset.endpointSast || '', @@ -79,6 +82,8 @@ export default function initDiffsApp(store = notesStore) { render(createElement) { return createElement('diffs-app', { props: { + projectPath: cleanLeadingSeparator(this.projectPath), + iid: this.iid, endpointCoverage: this.endpointCoverage, endpointCodequality: this.endpointCodequality, endpointSast: this.endpointSast, diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 7c68b5f69f1..ed8ae795bda 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -254,6 +254,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { if (totalLoaded === pagination.total_pages || pagination.total_pages === null) { commit(types.SET_RETRIEVING_BATCHES, false); + eventHub.$emit('doneLoadingBatches'); // We need to check that the currentDiffFileId points to a file that exists if ( @@ -879,6 +880,7 @@ export function switchToFullDiffFromRenamedFile({ commit }, { diffFile }) { ...diffFile.alternate_viewer, automaticallyCollapsed: false, manuallyCollapsed: false, + forceOpen: false, }, }); commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: diffFile.file_path, lines }); @@ -893,6 +895,10 @@ export const setFileCollapsedAutomatically = ({ commit }, { filePath, collapsed commit(types.SET_FILE_COLLAPSED, { filePath, collapsed, trigger: DIFF_FILE_AUTOMATIC_COLLAPSE }); }; +export function setFileForcedOpen({ commit }, { filePath, forced }) { + commit(types.SET_FILE_FORCED_OPEN, { filePath, forced }); +} + export const setSuggestPopoverDismissed = ({ commit, state }) => axios .post(state.dismissEndpoint, { diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index 3df491503a4..c2177bacbcc 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -39,6 +39,7 @@ export const REQUEST_FULL_DIFF = 'REQUEST_FULL_DIFF'; export const RECEIVE_FULL_DIFF_SUCCESS = 'RECEIVE_FULL_DIFF_SUCCESS'; export const RECEIVE_FULL_DIFF_ERROR = 'RECEIVE_FULL_DIFF_ERROR'; export const SET_FILE_COLLAPSED = 'SET_FILE_COLLAPSED'; +export const SET_FILE_FORCED_OPEN = 'SET_FILE_FORCED_OPEN'; export const SET_CURRENT_VIEW_DIFF_FILE_LINES = 'SET_CURRENT_VIEW_DIFF_FILE_LINES'; export const ADD_CURRENT_VIEW_DIFF_FILE_LINES = 'ADD_CURRENT_VIEW_DIFF_FILE_LINES'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 3af2d6ee6b1..31369b169f5 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -349,6 +349,11 @@ export default { } } }, + [types.SET_FILE_FORCED_OPEN](state, { filePath, forced = true }) { + const file = state.diffFiles.find((f) => f.file_path === filePath); + + Vue.set(file.viewer, 'forceOpen', forced); + }, [types.SET_CURRENT_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) { const file = state.diffFiles.find((f) => f.file_path === filePath); diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 307c41a98f8..15d2ab71bc8 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -18,8 +18,7 @@ import { EXPANDED_LINE_TYPE, } from '../constants'; import { prepareRawDiffFile } from '../utils/diff_file'; - -const SHA1 = /\b([a-f0-9]{40})\b/; +import { extractFileHash } from '../utils/merge_request'; export const isAdded = (line) => ['new', 'new-nonewline'].includes(line.type); export const isRemoved = (line) => ['old', 'old-nonewline'].includes(line.type); @@ -571,14 +570,16 @@ export function isUrlHashFileHeader(urlHash = '') { } export function parseUrlHashAsFileHash(urlHash = '', currentDiffFileId = '') { - const isNoteLink = isUrlHashNoteLink(urlHash); - let id = urlHash.replace(/^#/, ''); + const hashless = urlHash.replace(/^#/, ''); + const isNoteLink = isUrlHashNoteLink(hashless); + const extractedSha1 = extractFileHash({ input: hashless }); + let id = extractedSha1; if (isNoteLink && currentDiffFileId) { id = currentDiffFileId; - } else if (isUrlHashFileHeader(urlHash)) { - id = id.replace('diff-content-', ''); - } else if (!SHA1.test(id) || isNoteLink) { + } else if (isUrlHashFileHeader(hashless)) { + id = hashless.replace('diff-content-', ''); + } else if (!extractedSha1 || isNoteLink) { id = null; } diff --git a/app/assets/javascripts/diffs/utils/diff_file.js b/app/assets/javascripts/diffs/utils/diff_file.js index 98e1c1cc849..f20ae6464ae 100644 --- a/app/assets/javascripts/diffs/utils/diff_file.js +++ b/app/assets/javascripts/diffs/utils/diff_file.js @@ -35,6 +35,7 @@ function collapsed(file) { return { automaticallyCollapsed: viewer.automaticallyCollapsed || viewer.collapsed || false, manuallyCollapsed: null, + forceOpen: false, }; } diff --git a/app/assets/javascripts/diffs/utils/merge_request.js b/app/assets/javascripts/diffs/utils/merge_request.js index bc81c0b0a05..a74c9fe7fac 100644 --- a/app/assets/javascripts/diffs/utils/merge_request.js +++ b/app/assets/javascripts/diffs/utils/merge_request.js @@ -1,6 +1,7 @@ import { ZERO_CHANGES_ALT_DISPLAY } from '../constants'; const endpointRE = /^(\/?(.+\/)+(.+)\/-\/merge_requests\/(\d+)).*$/i; +const SHA1RE = /([a-f0-9]{40})/g; function getVersionInfo({ endpoint } = {}) { const dummyRoot = 'https://gitlab.com'; @@ -51,3 +52,9 @@ export function getDerivedMergeRequestInformation({ endpoint } = {}) { startSha, }; } + +export function extractFileHash({ input = '' } = {}) { + const matches = input.match(SHA1RE); + + return matches?.[0]; +} diff --git a/app/assets/javascripts/diffs/utils/sort_findings_by_file.js b/app/assets/javascripts/diffs/utils/sort_findings_by_file.js new file mode 100644 index 00000000000..3a285e80ace --- /dev/null +++ b/app/assets/javascripts/diffs/utils/sort_findings_by_file.js @@ -0,0 +1,17 @@ +export function sortFindingsByFile(newErrors = []) { + const files = {}; + newErrors.forEach(({ filePath, line, description, severity }) => { + if (!files[filePath]) { + files[filePath] = []; + } + files[filePath].push({ line, description, severity: severity.toLowerCase() }); + }); + + const sortedFiles = Object.keys(files) + .sort() + .reduce((acc, key) => { + acc[key] = files[key]; + return acc; + }, {}); + return { files: sortedFiles }; +} diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index 2dba919cf58..0420ffb82f5 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -9,22 +9,27 @@ "format": "uri" }, "image": { - "$ref": "#/definitions/image" + "$ref": "#/definitions/image", + "markdownDescription": "Defining `image` globally is deprecated. Use [`default`](https://docs.gitlab.com/ee/ci/yaml/#default) instead. [Learn more](https://docs.gitlab.com/ee/ci/yaml/#globally-defined-image-services-cache-before_script-after_script)." }, "services": { - "$ref": "#/definitions/services" + "$ref": "#/definitions/services", + "markdownDescription": "Defining `services` globally is deprecated. Use [`default`](https://docs.gitlab.com/ee/ci/yaml/#default) instead. [Learn more](https://docs.gitlab.com/ee/ci/yaml/#globally-defined-image-services-cache-before_script-after_script)." }, "before_script": { - "$ref": "#/definitions/before_script" + "$ref": "#/definitions/before_script", + "markdownDescription": "Defining `before_script` globally is deprecated. Use [`default`](https://docs.gitlab.com/ee/ci/yaml/#default) instead. [Learn more](https://docs.gitlab.com/ee/ci/yaml/#globally-defined-image-services-cache-before_script-after_script)." }, "after_script": { - "$ref": "#/definitions/after_script" + "$ref": "#/definitions/after_script", + "markdownDescription": "Defining `after_script` globally is deprecated. Use [`default`](https://docs.gitlab.com/ee/ci/yaml/#default) instead. [Learn more](https://docs.gitlab.com/ee/ci/yaml/#globally-defined-image-services-cache-before_script-after_script)." }, "variables": { "$ref": "#/definitions/globalVariables" }, "cache": { - "$ref": "#/definitions/cache" + "$ref": "#/definitions/cache", + "markdownDescription": "Defining `cache` globally is deprecated. Use [`default`](https://docs.gitlab.com/ee/ci/yaml/#default) instead. [Learn more](https://docs.gitlab.com/ee/ci/yaml/#globally-defined-image-services-cache-before_script-after_script)." }, "!reference": { "$ref": "#/definitions/!reference" @@ -744,39 +749,61 @@ } } }, - "before_script": { - "type": "array", - "markdownDescription": "Defines scripts that should run *before* the job. Can be set globally or per job. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#before_script).", - "items": { - "anyOf": [ - { - "type": "string" + "script": { + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, - { - "type": "array", - "items": { - "type": "string" - } + "minItems": 1 + } + ] + }, + "optional_script": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] } - ] - } + } + ] + }, + "before_script": { + "$ref": "#/definitions/optional_script", + "markdownDescription": "Defines scripts that should run *before* the job. Can be set globally or per job. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#before_script)." }, "after_script": { - "type": "array", - "markdownDescription": "Defines scripts that should run *after* the job. Can be set globally or per job. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#after_script).", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } + "$ref": "#/definitions/optional_script", + "markdownDescription": "Defines scripts that should run *after* the job. Can be set globally or per job. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#after_script)." }, "rules": { "type": [ @@ -1508,30 +1535,8 @@ "$ref": "#/definitions/secrets" }, "script": { - "markdownDescription": "Shell scripts executed by the Runner. The only required property of jobs. Be careful with special characters (e.g. `:`, `{`, `}`, `&`) and use single or double quotes to avoid issues. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#script)", - "oneOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "minItems": 1 - } - ] + "$ref": "#/definitions/script", + "markdownDescription": "Shell scripts executed by the Runner. The only required property of jobs. Be careful with special characters (e.g. `:`, `{`, `}`, `&`) and use single or double quotes to avoid issues. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#script)" }, "stage": { "description": "Define what stage the job will run in.", @@ -2145,30 +2150,8 @@ "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 - } - ] + "$ref": "#/definitions/optional_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)." } }, "additionalProperties": false diff --git a/app/assets/javascripts/editor/source_editor.js b/app/assets/javascripts/editor/source_editor.js index d585dc009e6..0c9315701eb 100644 --- a/app/assets/javascripts/editor/source_editor.js +++ b/app/assets/javascripts/editor/source_editor.js @@ -1,5 +1,4 @@ import { editor as monacoEditor, Uri } from 'monaco-editor'; -import { waitForCSSLoaded } from '~/helpers/startup_css_helper'; import { defaultEditorOptions } from '~/ide/lib/editor_options'; import languages from '~/ide/lib/languages'; import { registerLanguages } from '~/ide/utils'; @@ -128,9 +127,7 @@ export default class SourceEditor { this.extensionsStore, ); - waitForCSSLoaded(() => { - instance.layout(); - }); + instance.layout(); let model; const language = instanceOptions.language || getBlobLanguage(blobPath); diff --git a/app/assets/javascripts/emoji/awards_app/index.js b/app/assets/javascripts/emoji/awards_app/index.js index f3d72c2dba5..965cb4f421a 100644 --- a/app/assets/javascripts/emoji/awards_app/index.js +++ b/app/assets/javascripts/emoji/awards_app/index.js @@ -9,14 +9,18 @@ export default (el) => { if (!el) return null; const { - dataset: { path }, + dataset: { path, newCustomEmojiPath }, } = el; const canAwardEmoji = parseBoolean(el.dataset.canAwardEmoji); + const showDefaultAwardEmojis = parseBoolean(el.dataset.showDefaultAwardEmojis); return new Vue({ el, name: 'AwardsListRoot', store: createstore(), + provide: { + newCustomEmojiPath, + }, computed: { ...mapState(['currentUserId', 'canAwardEmoji', 'awards']), }, @@ -35,7 +39,7 @@ export default (el) => { awards: this.awards, canAwardEmoji: this.canAwardEmoji, currentUserId: this.currentUserId, - defaultAwards: ['thumbsup', 'thumbsdown'], + defaultAwards: showDefaultAwardEmojis ? ['thumbsup', 'thumbsdown'] : [], selectedClass: 'selected', }, on: { diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue index fcc54f17466..238f0d81b22 100644 --- a/app/assets/javascripts/emoji/components/picker.vue +++ b/app/assets/javascripts/emoji/components/picker.vue @@ -1,6 +1,6 @@ <!-- eslint-disable vue/multi-word-component-names --> <script> -import { GlIcon, GlDropdown, GlSearchBoxByType } from '@gitlab/ui'; +import { GlIcon, GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; import { findLastIndex } from 'lodash'; import VirtualList from 'vue-virtual-scroll-list'; import { CATEGORY_NAMES, getEmojiCategoryMap, state } from '~/emoji'; @@ -13,11 +13,17 @@ export default { components: { GlIcon, GlDropdown, + GlDropdownItem, GlSearchBoxByType, VirtualList, Category, EmojiList, }, + inject: { + newCustomEmojiPath: { + default: '', + }, + }, props: { toggleClass: { type: [Array, String, Object], @@ -167,6 +173,11 @@ export default { </virtual-list> </template> </emoji-list> + <template v-if="newCustomEmojiPath" #footer> + <gl-dropdown-item :href="newCustomEmojiPath"> + {{ __('Create new emoji') }} + </gl-dropdown-item> + </template> </gl-dropdown> </div> </template> diff --git a/app/assets/javascripts/environments/components/canary_ingress.vue b/app/assets/javascripts/environments/components/canary_ingress.vue index 30f3f9dfc75..ef3c6210ce1 100644 --- a/app/assets/javascripts/environments/components/canary_ingress.vue +++ b/app/assets/javascripts/environments/components/canary_ingress.vue @@ -1,16 +1,12 @@ <script> -import { GlDropdown, GlDropdownItem, GlModalDirective as GlModal } from '@gitlab/ui'; -import { uniqueId } from 'lodash'; +import { GlCollapsibleListbox } from '@gitlab/ui'; +import uniqueId from 'lodash/uniqueId'; import { s__ } from '~/locale'; import { CANARY_UPDATE_MODAL } from '../constants'; export default { components: { - GlDropdown, - GlDropdownItem, - }, - directives: { - GlModal, + GlCollapsibleListbox, }, props: { canaryIngress: { @@ -25,8 +21,10 @@ export default { }, ingressOptions: Array(100 / 5 + 1) .fill(0) - .map((_, i) => i * 5), - + .map((_, i) => { + const value = i * 5; + return { value, text: value.toString() }; + }), translations: { stableLabel: s__('CanaryIngress|Stable'), canaryLabel: s__('CanaryIngress|Canary'), @@ -59,17 +57,19 @@ export default { return this.canaryIngress.canary_weight; }, stableWeight() { - return (100 - this.weight).toString(); + return 100 - this.weight; }, canaryWeight() { - return this.weight.toString(); + return this.weight; }, }, methods: { changeCanary(weight) { + this.$root.$emit('bv::show::modal', CANARY_UPDATE_MODAL); this.$emit('change', weight); }, changeStable(weight) { + this.$root.$emit('bv::show::modal', CANARY_UPDATE_MODAL); this.$emit('change', 100 - weight); }, }, @@ -81,40 +81,27 @@ export default { <label :for="stableWeightId" :class="$options.css.label" class="gl-rounded-top-left-base"> {{ $options.translations.stableLabel }} </label> - <gl-dropdown + <gl-collapsible-listbox :id="stableWeightId" - :text="stableWeight" - data-testid="stable-weight" - class="gl-w-full" - toggle-class="gl-rounded-top-left-none! gl-rounded-top-right-none! gl-rounded-bottom-right-none!" - > - <gl-dropdown-item - v-for="option in $options.ingressOptions" - :key="option" - v-gl-modal="$options.CANARY_UPDATE_MODAL" - @click="changeStable(option)" - >{{ option }}</gl-dropdown-item - > - </gl-dropdown> + :selected="stableWeight" + :items="$options.ingressOptions" + class="gl-min-w-full gl-text-black-normal" + toggle-class="gl-min-w-full gl-rounded-top-left-none! gl-rounded-top-right-none! gl-rounded-bottom-right-none!" + @select="changeStable" + /> </div> <div class="gl-display-flex gl-display-flex gl-flex-direction-column"> <label :for="canaryWeightId" :class="$options.css.label" class="gl-rounded-top-right-base">{{ $options.translations.canaryLabel }}</label> - <gl-dropdown + <gl-collapsible-listbox :id="canaryWeightId" - :text="canaryWeight" - data-testid="canary-weight" - toggle-class="gl-rounded-top-left-none! gl-rounded-top-right-none! gl-rounded-bottom-left-none! gl-border-l-none!" - > - <gl-dropdown-item - v-for="option in $options.ingressOptions" - :key="option" - v-gl-modal="$options.CANARY_UPDATE_MODAL" - @click="changeCanary(option)" - >{{ option }}</gl-dropdown-item - > - </gl-dropdown> + :selected="canaryWeight" + :items="$options.ingressOptions" + class="gl-min-w-full" + toggle-class="gl-min-w-full gl-rounded-top-left-none! gl-rounded-top-right-none! gl-rounded-bottom-right-none!" + @select="changeCanary" + /> </div> </section> </template> diff --git a/app/assets/javascripts/environments/components/empty_state.vue b/app/assets/javascripts/environments/components/empty_state.vue index 3ac32f0d045..5b9cc2f3d21 100644 --- a/app/assets/javascripts/environments/components/empty_state.vue +++ b/app/assets/javascripts/environments/components/empty_state.vue @@ -42,7 +42,7 @@ export default { }; </script> <template> - <gl-empty-state class="gl-layout-w-limited" :title="title"> + <gl-empty-state class="gl-layout-w-limited gl-mx-auto" :title="title"> <template #description> <gl-sprintf :message="content"> <template #link="{ content: contentToDisplay }"> @@ -51,10 +51,10 @@ export default { </gl-sprintf> </template> <template v-if="!hasTerm" #actions> - <gl-button :href="newEnvironmentPath" variant="confirm"> + <gl-button class="gl-mx-2 gl-mb-3" :href="newEnvironmentPath" variant="confirm"> {{ $options.i18n.newEnvironmentButtonLabel }} </gl-button> - <gl-button @click="$emit('enable-review')"> + <gl-button class="gl-mx-2 gl-mb-3" @click="$emit('enable-review')"> {{ $options.i18n.enablingReviewButtonLabel }} </gl-button> </template> diff --git a/app/assets/javascripts/environments/components/environment_form.vue b/app/assets/javascripts/environments/components/environment_form.vue index 846f2cf73b2..8ebba0e27bb 100644 --- a/app/assets/javascripts/environments/components/environment_form.vue +++ b/app/assets/javascripts/environments/components/environment_form.vue @@ -190,13 +190,12 @@ export default { } return { basePath: this.kasTunnelUrl, - baseOptions: { - headers: { - 'GitLab-Agent-Id': getIdFromGraphQLId(this.selectedAgentId), - ...csrf.headers, - }, - withCredentials: true, + headers: { + 'GitLab-Agent-Id': getIdFromGraphQLId(this.selectedAgentId), + 'Content-Type': 'application/json', + ...csrf.headers, }, + credentials: 'include', }; }, }, diff --git a/app/assets/javascripts/environments/components/kubernetes_overview.vue b/app/assets/javascripts/environments/components/kubernetes_overview.vue index 0e52a80c2c5..252ced6391d 100644 --- a/app/assets/javascripts/environments/components/kubernetes_overview.vue +++ b/app/assets/javascripts/environments/components/kubernetes_overview.vue @@ -61,10 +61,12 @@ export default { k8sAccessConfiguration() { return { basePath: this.kasTunnelUrl, - baseOptions: { - headers: { 'GitLab-Agent-Id': this.gitlabAgentId, ...csrf.headers }, - withCredentials: true, + headers: { + 'GitLab-Agent-Id': this.gitlabAgentId, + 'Content-Type': 'application/json', + ...csrf.headers, }, + credentials: 'include', }; }, clusterHealthStatus() { diff --git a/app/assets/javascripts/environments/components/kubernetes_pods.vue b/app/assets/javascripts/environments/components/kubernetes_pods.vue index aded3a4d0c4..3f040f1f40a 100644 --- a/app/assets/javascripts/environments/components/kubernetes_pods.vue +++ b/app/assets/javascripts/environments/components/kubernetes_pods.vue @@ -23,7 +23,7 @@ export default { return data?.k8sPods || []; }, error(error) { - this.error = error; + this.error = error.message; this.$emit('cluster-error', this.error); }, watchLoading(isLoading) { diff --git a/app/assets/javascripts/environments/components/kubernetes_summary.vue b/app/assets/javascripts/environments/components/kubernetes_summary.vue index b00e82809f6..e2fbc6fd2e7 100644 --- a/app/assets/javascripts/environments/components/kubernetes_summary.vue +++ b/app/assets/javascripts/environments/components/kubernetes_summary.vue @@ -30,7 +30,7 @@ export default { return data?.k8sWorkloads || {}; }, error(error) { - this.$emit('cluster-error', error); + this.$emit('cluster-error', error.message); }, result() { this.checkFailed(); diff --git a/app/assets/javascripts/environments/components/kubernetes_tabs.vue b/app/assets/javascripts/environments/components/kubernetes_tabs.vue index 4492d209e3b..7c699eec412 100644 --- a/app/assets/javascripts/environments/components/kubernetes_tabs.vue +++ b/app/assets/javascripts/environments/components/kubernetes_tabs.vue @@ -24,13 +24,14 @@ export default { variables() { return { configuration: this.configuration, + namespace: this.namespace, }; }, update(data) { return data?.k8sServices || []; }, error(error) { - this.$emit('cluster-error', error); + this.$emit('cluster-error', error.message); }, }, }, @@ -139,6 +140,7 @@ export default { :configuration="configuration" @loading="$emit('loading', $event)" @failed="$emit('failed')" + @cluster-error="$emit('cluster-error', $event)" /> <gl-tab> diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue index 149cab21acd..aacb460a817 100644 --- a/app/assets/javascripts/environments/components/new_environment_item.vue +++ b/app/assets/javascripts/environments/components/new_environment_item.vue @@ -185,7 +185,7 @@ export default { }, update(data) { this.clusterAgent = data?.project?.environment?.clusterAgent; - this.kubernetesNamespace = data?.project?.environment?.kubernetesNamespace; + this.kubernetesNamespace = data?.project?.environment?.kubernetesNamespace || ''; this.fluxResourcePath = data?.project?.environment?.fluxResourcePath || ''; }, }); diff --git a/app/assets/javascripts/environments/environment_details/deployments_table.vue b/app/assets/javascripts/environments/environment_details/deployments_table.vue index 261d8106438..e46411f4d2c 100644 --- a/app/assets/javascripts/environments/environment_details/deployments_table.vue +++ b/app/assets/javascripts/environments/environment_details/deployments_table.vue @@ -48,12 +48,17 @@ export default { <deployment-job :job="item.job" /> </template> <template #cell(created)="{ item }"> - <time-ago-tooltip :time="item.created" data-testid="deployment-created-at" /> + <time-ago-tooltip + :time="item.created" + enable-truncation + data-testid="deployment-created-at" + /> </template> <template #cell(deployed)="{ item }"> <time-ago-tooltip v-if="item.deployed" :time="item.deployed" + enable-truncation data-testid="deployment-deployed-at" /> </template> diff --git a/app/assets/javascripts/environments/environment_details/index.vue b/app/assets/javascripts/environments/environment_details/index.vue index aa836299bcc..6e3ec04ba3b 100644 --- a/app/assets/javascripts/environments/environment_details/index.vue +++ b/app/assets/javascripts/environments/environment_details/index.vue @@ -150,10 +150,10 @@ export default { }, }, errorCaptured(error) { - Sentry.withScope((scope) => { - scope.setTag('vue_component', 'EnvironmentDetailsIndex'); - - Sentry.captureException(error); + Sentry.captureException(error, { + tags: { + vue_component: 'EnvironmentDetailsIndex', + }, }); }, mounted() { diff --git a/app/assets/javascripts/environments/graphql/queries/k8s_services.query.graphql b/app/assets/javascripts/environments/graphql/queries/k8s_services.query.graphql index d97849eecc1..8fc4a54b08b 100644 --- a/app/assets/javascripts/environments/graphql/queries/k8s_services.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/k8s_services.query.graphql @@ -1,5 +1,5 @@ -query getK8sServices($configuration: LocalConfiguration) { - k8sServices(configuration: $configuration) @client { +query getK8sServices($configuration: LocalConfiguration, $namespace: String) { + k8sServices(configuration: $configuration, namespace: $namespace) @client { metadata { name namespace diff --git a/app/assets/javascripts/environments/graphql/resolvers/flux.js b/app/assets/javascripts/environments/graphql/resolvers/flux.js index f9ca35a3165..d39b1bed7b6 100644 --- a/app/assets/javascripts/environments/graphql/resolvers/flux.js +++ b/app/assets/javascripts/environments/graphql/resolvers/flux.js @@ -23,7 +23,7 @@ const buildFluxResourceUrl = ({ }; const getFluxResourceStatus = (configuration, url) => { - const { headers } = configuration.baseOptions; + const { headers } = configuration; const withCredentials = true; return axios @@ -37,7 +37,7 @@ const getFluxResourceStatus = (configuration, url) => { }; const getFluxResources = (configuration, url) => { - const { headers } = configuration.baseOptions; + const { headers } = configuration; const withCredentials = true; return axios diff --git a/app/assets/javascripts/environments/graphql/resolvers/kubernetes.js b/app/assets/javascripts/environments/graphql/resolvers/kubernetes.js index 9ab65d0bb7f..67a472dac93 100644 --- a/app/assets/javascripts/environments/graphql/resolvers/kubernetes.js +++ b/app/assets/javascripts/environments/graphql/resolvers/kubernetes.js @@ -44,30 +44,41 @@ const mapWorkloadItems = (items, kind) => { }); }; -const handleClusterError = (err) => { - const error = err?.response?.data?.message ? new Error(err.response.data.message) : err; - throw error; +const handleClusterError = async (err) => { + if (!err.response) { + throw err; + } + + const errorData = await err.response.json(); + throw errorData; }; export default { k8sPods(_, { configuration, namespace }) { const coreV1Api = new CoreV1Api(new Configuration(configuration)); const podsApi = namespace - ? coreV1Api.listCoreV1NamespacedPod(namespace) + ? coreV1Api.listCoreV1NamespacedPod({ namespace }) : coreV1Api.listCoreV1PodForAllNamespaces(); return podsApi - .then((res) => res?.data?.items || []) - .catch((err) => { - handleClusterError(err); + .then((res) => res?.items || []) + .catch(async (err) => { + try { + await handleClusterError(err); + } catch (error) { + throw new Error(error.message); + } }); }, - k8sServices(_, { configuration }) { + k8sServices(_, { configuration, namespace }) { const coreV1Api = new CoreV1Api(new Configuration(configuration)); - return coreV1Api - .listCoreV1ServiceForAllNamespaces() + const servicesApi = namespace + ? coreV1Api.listCoreV1NamespacedService({ namespace }) + : coreV1Api.listCoreV1ServiceForAllNamespaces(); + + return servicesApi .then((res) => { - const items = res?.data?.items || []; + const items = res?.items || []; return items.map((item) => { const { type, clusterIP, externalIP, ports } = item.spec; return { @@ -81,24 +92,28 @@ export default { }; }); }) - .catch((err) => { - handleClusterError(err); + .catch(async (err) => { + try { + await handleClusterError(err); + } catch (error) { + throw new Error(error.message); + } }); }, k8sWorkloads(_, { configuration, namespace }) { - const appsV1api = new AppsV1Api(configuration); - const batchV1api = new BatchV1Api(configuration); + const appsV1api = new AppsV1Api(new Configuration(configuration)); + const batchV1api = new BatchV1Api(new Configuration(configuration)); let promises; if (namespace) { promises = [ - appsV1api.listAppsV1NamespacedDeployment(namespace), - appsV1api.listAppsV1NamespacedDaemonSet(namespace), - appsV1api.listAppsV1NamespacedStatefulSet(namespace), - appsV1api.listAppsV1NamespacedReplicaSet(namespace), - batchV1api.listBatchV1NamespacedJob(namespace), - batchV1api.listBatchV1NamespacedCronJob(namespace), + appsV1api.listAppsV1NamespacedDeployment({ namespace }), + appsV1api.listAppsV1NamespacedDaemonSet({ namespace }), + appsV1api.listAppsV1NamespacedStatefulSet({ namespace }), + appsV1api.listAppsV1NamespacedReplicaSet({ namespace }), + batchV1api.listBatchV1NamespacedJob({ namespace }), + batchV1api.listBatchV1NamespacedCronJob({ namespace }), ]; } else { promises = [ @@ -120,15 +135,18 @@ export default { CronJobList: [], }; - return Promise.allSettled(promises).then((results) => { + return Promise.allSettled(promises).then(async (results) => { if (results.every((res) => res.status === 'rejected')) { const error = results[0].reason; - const errorMessage = error?.response?.data?.message ?? error; - throw new Error(errorMessage); + try { + await handleClusterError(error); + } catch (err) { + throw new Error(err.message); + } } for (const promiseResult of results) { - if (promiseResult.status === 'fulfilled' && promiseResult?.value?.data) { - const { kind, items } = promiseResult.value.data; + if (promiseResult.status === 'fulfilled' && promiseResult?.value) { + const { kind, items } = promiseResult.value; if (items?.length > 0) { summaryList[kind] = mapWorkloadItems(items, kind); @@ -145,11 +163,14 @@ export default { return namespacesApi .then((res) => { - return res?.data?.items || []; + return res?.items || []; }) - .catch((err) => { - const error = err?.response?.data?.reason || err; - throw new Error(humanizeClusterErrors(error)); + .catch(async (error) => { + try { + await handleClusterError(error); + } catch (err) { + throw new Error(humanizeClusterErrors(err.reason)); + } }); }, }; 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 9b30ec4afbb..4d4bae12570 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -493,7 +493,11 @@ export default { </div> <!-- Get Started with ET --> <div v-else> - <gl-empty-state :title="__('Get started with error tracking')" :svg-path="illustrationPath"> + <gl-empty-state + :title="__('Get started with error tracking')" + :svg-path="illustrationPath" + :svg-height="null" + > <template #description> <div> <span>{{ __('Monitor your errors directly in GitLab.') }}</span> diff --git a/app/assets/javascripts/feature_flags/components/empty_state.vue b/app/assets/javascripts/feature_flags/components/empty_state.vue index 60aeb297700..ccc984ee7a0 100644 --- a/app/assets/javascripts/feature_flags/components/empty_state.vue +++ b/app/assets/javascripts/feature_flags/components/empty_state.vue @@ -74,6 +74,7 @@ export default { :title="errorTitle" :description="s__('FeatureFlags|Try again in a few moments or contact your support team.')" :svg-path="errorStateSvgPath" + :svg-height="null" data-testid="error-state" /> 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 0fde87dd0ba..7cc87544be9 100644 --- a/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue +++ b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue @@ -93,7 +93,7 @@ export default { type="number" min="0" max="100" - size="xs" + width="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 0acb0d4366c..a46eee7b130 100644 --- a/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue +++ b/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue @@ -59,7 +59,7 @@ export default { type="number" min="0" max="100" - size="xs" + width="xs" @input="onPercentageChange" /> <span class="gl-ml-2">%</span> diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 264427f5806..99d22b1330b 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -129,7 +129,7 @@ class GfmAutoComplete { this.dataSources = dataSources; this.cachedData = {}; this.isLoadingData = {}; - this.previousQuery = ''; + this.previousQuery = undefined; } setup(input, enableMap = defaultAutocompleteConfig) { @@ -776,15 +776,19 @@ class GfmAutoComplete { return $.fn.atwho.default.callbacks.sorter(query, items, searchKey); }, filter(query, data, searchKey) { + if (GfmAutoComplete.isTypeWithBackendFiltering(this.at)) { + if (GfmAutoComplete.isLoading(data) || self.previousQuery !== query) { + self.previousQuery = query; + self.fetchData(this.$inputor, this.at, query); + return data; + } + } + if (GfmAutoComplete.isLoading(data)) { self.fetchData(this.$inputor, this.at); return data; } - if (GfmAutoComplete.isTypeWithBackendFiltering(this.at) && self.previousQuery !== query) { - self.fetchData(this.$inputor, this.at, query); - self.previousQuery = query; - return data; - } + return $.fn.atwho.default.callbacks.filter(query, data, searchKey); }, beforeInsert(value) { @@ -828,14 +832,18 @@ class GfmAutoComplete { const dataSource = this.dataSources[GfmAutoComplete.atTypeMap[at]]; if (GfmAutoComplete.isTypeWithBackendFiltering(at)) { - axios - .get(dataSource, { params: { search } }) - .then(({ data }) => { - this.loadData($input, at, data); - }) - .catch(() => { - this.isLoadingData[at] = false; - }); + if (this.cachedData[at]?.[search]) { + this.loadData($input, at, this.cachedData[at][search], { search }); + } else { + axios + .get(dataSource, { params: { search } }) + .then(({ data }) => { + this.loadData($input, at, data, { search }); + }) + .catch(() => { + this.isLoadingData[at] = false; + }); + } } else if (this.cachedData[at]) { this.loadData($input, at, this.cachedData[at]); } else if (GfmAutoComplete.atTypeMap[at] === 'emojis') { @@ -853,9 +861,19 @@ class GfmAutoComplete { } } - loadData($input, at, data) { + loadData($input, at, data, { search } = {}) { this.isLoadingData[at] = false; - this.cachedData[at] = data; + + if (search !== undefined) { + if (this.cachedData[at] === undefined) { + this.cachedData[at] = {}; + } + + this.cachedData[at][search] = data; + } else { + this.cachedData[at] = data; + } + $input.atwho('load', at, data); // This trigger at.js again // otherwise we would be stuck with loading until the user types diff --git a/app/assets/javascripts/google_cloud/databases/cloudsql/instance_table.vue b/app/assets/javascripts/google_cloud/databases/cloudsql/instance_table.vue index 823895214df..f97c1e54094 100644 --- a/app/assets/javascripts/google_cloud/databases/cloudsql/instance_table.vue +++ b/app/assets/javascripts/google_cloud/databases/cloudsql/instance_table.vue @@ -57,6 +57,7 @@ export default { :title="$options.i18n.noInstancesTitle" :description="$options.i18n.noInstancesDescription" :svg-path="emptyIllustrationUrl" + :svg-height="null" /> <div v-else> diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js index a9ae9a5af82..0cb25fbaeb5 100644 --- a/app/assets/javascripts/google_tag_manager/index.js +++ b/app/assets/javascripts/google_tag_manager/index.js @@ -1,310 +1 @@ -import { v4 as uuidv4 } from 'uuid'; -import { logError } from '~/lib/logger'; - -const SKU_PREMIUM = '2c92a00d76f0d5060176f2fb0a5029ff'; -const SKU_ULTIMATE = '2c92a0ff76f0d5250176f2f8c86f305a'; -const PRODUCT_INFO = { - [SKU_PREMIUM]: { - // eslint-disable-next-line @gitlab/require-i18n-strings - name: 'Premium', - id: '0002', - price: '228', - variant: 'SaaS', - }, - [SKU_ULTIMATE]: { - // eslint-disable-next-line @gitlab/require-i18n-strings - name: 'Ultimate', - id: '0001', - price: '1188', - variant: 'SaaS', - }, -}; -const EMPTY_NAMESPACE_ID_VALUE = 'not available'; - -const generateProductInfo = (sku, quantity) => { - const product = PRODUCT_INFO[sku]; - - if (!product) { - logError('Unexpected product sku provided to generateProductInfo'); - return {}; - } - - const productInfo = { - ...product, - brand: 'GitLab', - category: 'DevOps', - quantity, - }; - - return productInfo; -}; - -const isSupported = () => Boolean(window.dataLayer) && gon.features?.gitlabGtmDatalayer; -// gon.features.gitlabGtmDatalayer is set by writing -// `push_frontend_feature_flag(:gitlab_gtm_datalayer, type: :ops)` -// to the appropriate controller -// window.dataLayer is set by adding partials to the appropriate view found in -// views/layouts/_google_tag_manager_body.html.haml and _google_tag_manager_head.html.haml - -const pushEvent = (event, args = {}) => { - if (!window.dataLayer) { - return; - } - - try { - window.dataLayer.push({ - event, - ...args, - }); - } catch (e) { - logError('Unexpected error while pushing to dataLayer', e); - } -}; - -const pushEnhancedEcommerceEvent = (event, args = {}) => { - if (!window.dataLayer) { - return; - } - - try { - window.dataLayer.push({ ecommerce: null }); // Clear the previous ecommerce object - window.dataLayer.push({ - event, - ...args, - }); - } catch (e) { - logError('Unexpected error while pushing to dataLayer', e); - } -}; - -const pushAccountSubmit = (accountType, accountMethod) => - pushEvent('accountSubmit', { accountType, accountMethod }); - -const trackFormSubmission = (accountType) => { - const form = document.getElementById('new_new_user'); - form.addEventListener('submit', () => { - pushAccountSubmit(accountType, 'form'); - }); -}; - -const trackOmniAuthSubmission = (accountType) => { - const links = document.querySelectorAll('.js-oauth-login'); - links.forEach((link) => { - const { provider } = link.dataset; - link.addEventListener('click', () => { - pushAccountSubmit(accountType, provider); - }); - }); -}; - -export const trackFreeTrialAccountSubmissions = () => { - if (!isSupported()) { - return; - } - - trackFormSubmission('freeThirtyDayTrial'); - trackOmniAuthSubmission('freeThirtyDayTrial'); -}; - -export const trackNewRegistrations = () => { - if (!isSupported()) { - return; - } - - trackFormSubmission('standardSignUp'); - trackOmniAuthSubmission('standardSignUp'); -}; - -export const trackSaasTrialSubmit = () => { - if (!isSupported()) { - return; - } - - pushEvent('saasTrialSubmit'); -}; - -export const trackSaasTrialGroup = () => { - if (!isSupported()) { - return; - } - - const form = document.querySelector('.js-saas-trial-group'); - - if (!form) return; - - form.addEventListener('submit', () => { - pushEvent('saasTrialGroup'); - }); -}; - -export const trackProjectImport = () => { - if (!isSupported()) { - return; - } - - const importButtons = document.querySelectorAll('.js-import-project-btn'); - importButtons.forEach((button) => { - button.addEventListener('click', () => { - const { platform } = button.dataset; - pushEvent('projectImport', { platform }); - }); - }); -}; - -export const trackSaasTrialGetStarted = () => { - if (!isSupported()) { - return; - } - - const getStartedButton = document.querySelector('.js-get-started-btn'); - getStartedButton.addEventListener('click', () => { - pushEvent('saasTrialGetStarted'); - }); -}; - -export const trackTrialAcceptTerms = () => { - if (!isSupported()) { - return; - } - - pushEvent('saasTrialAcceptTerms'); -}; - -export const trackCheckout = (selectedPlan, quantity) => { - if (!isSupported()) { - return; - } - - const product = generateProductInfo(selectedPlan, quantity); - - if (Object.keys(product).length === 0) { - return; - } - - const eventData = { - ecommerce: { - currencyCode: 'USD', - checkout: { - actionField: { step: 1 }, - products: [product], - }, - }, - }; - - // eslint-disable-next-line @gitlab/require-i18n-strings - pushEnhancedEcommerceEvent('EECCheckout', eventData); -}; - -export const getNamespaceId = () => { - return window.gl.snowplowStandardContext?.data?.namespace_id || EMPTY_NAMESPACE_ID_VALUE; -}; - -export const trackTransaction = (transactionDetails) => { - if (!isSupported()) { - return; - } - - const transactionId = uuidv4(); - const { paymentOption, revenue, tax, selectedPlan, quantity } = transactionDetails; - const product = generateProductInfo(selectedPlan, quantity); - const namespaceId = getNamespaceId(); - - if (Object.keys(product).length === 0) { - return; - } - - const eventData = { - ecommerce: { - currencyCode: 'USD', - purchase: { - actionField: { - id: transactionId, - affiliation: 'GitLab', - option: paymentOption, - revenue: revenue.toString(), - tax: tax.toString(), - }, - products: [{ ...product, dimension36: namespaceId }], - }, - }, - }; - - pushEnhancedEcommerceEvent('EECtransactionSuccess', eventData); -}; - -export const pushEECproductAddToCartEvent = () => { - if (!isSupported()) { - return; - } - - window.dataLayer.push({ - event: 'EECproductAddToCart', - ecommerce: { - currencyCode: 'USD', - add: { - products: [ - { - name: 'CI/CD Minutes', - id: '0003', - price: '10', - brand: 'GitLab', - category: 'DevOps', - variant: 'add-on', - quantity: 1, - }, - ], - }, - }, - }); -}; - -export const trackAddToCartUsageTab = () => { - const getStartedButton = document.querySelector('.js-buy-additional-minutes'); - if (!getStartedButton) { - return; - } - getStartedButton.addEventListener('click', pushEECproductAddToCartEvent); -}; - -export const trackCombinedGroupProjectForm = () => { - if (!isSupported()) { - return; - } - - const form = document.querySelector('.js-groups-projects-form'); - form.addEventListener('submit', () => { - pushEvent('combinedGroupProjectFormSubmit'); - }); -}; - -export const trackCompanyForm = (aboutYourCompanyType) => { - if (!isSupported()) { - return; - } - - pushEvent('aboutYourCompanyFormSubmit', { aboutYourCompanyType }); -}; - -export const saasTrialWelcome = () => { - if (!isSupported()) { - return; - } - - const saasTrialWelcomeButton = document.querySelector('.js-trial-welcome-btn'); - - saasTrialWelcomeButton?.addEventListener('click', () => { - pushEvent('saasTrialWelcome'); - }); -}; - -export const saasTrialContinuousOnboarding = () => { - if (!isSupported()) { - return; - } - - const getStartedButton = document.querySelector('.js-get-started-btn'); - - getStartedButton?.addEventListener('click', () => { - pushEvent('saasTrialContinuousOnboarding'); - }); -}; +export const trackTrialAcceptTerms = () => {}; diff --git a/app/assets/javascripts/graphql_shared/client/is_showing_labels.query.graphql b/app/assets/javascripts/graphql_shared/client/is_showing_labels.query.graphql new file mode 100644 index 00000000000..dc16f7ad313 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/client/is_showing_labels.query.graphql @@ -0,0 +1,3 @@ +query isShowingLabels { + isShowingLabels @client +} diff --git a/app/assets/javascripts/graphql_shared/client/set_is_showing_labels.mutation.graphql b/app/assets/javascripts/graphql_shared/client/set_is_showing_labels.mutation.graphql new file mode 100644 index 00000000000..2f115291977 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/client/set_is_showing_labels.mutation.graphql @@ -0,0 +1,3 @@ +mutation setIsShowingLabels($isShowingLabels: Boolean!) { + setIsShowingLabels(isShowingLabels: $isShowingLabels) @client +} diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js index eb807bc7540..9537c9ef8a6 100644 --- a/app/assets/javascripts/graphql_shared/issuable_client.js +++ b/app/assets/javascripts/graphql_shared/issuable_client.js @@ -3,6 +3,8 @@ import VueApollo from 'vue-apollo'; import { defaultDataIdFromObject } from '@apollo/client/core'; import { concatPagination } from '@apollo/client/utilities'; import errorQuery from '~/boards/graphql/client/error.query.graphql'; +import selectedBoardItemsQuery from '~/boards/graphql/client/selected_board_items.query.graphql'; +import isShowingLabelsQuery from '~/graphql_shared/client/is_showing_labels.query.graphql'; import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql'; import createDefaultClient from '~/lib/graphql'; import typeDefs from '~/work_items/graphql/typedefs.graphql'; @@ -20,6 +22,20 @@ export const config = { : defaultDataIdFromObject(object); }, typePolicies: { + Query: { + fields: { + isShowingLabels: { + read(currentState) { + return currentState ?? true; + }, + }, + selectedBoardItems: { + read(currentState) { + return currentState ?? []; + }, + }, + }, + }, Project: { fields: { projectMembers: { @@ -77,14 +93,6 @@ export const config = { const incomingWidget = incoming.find( (w) => w.type && w.type === existingWidget.type, ); - // We don't want to override existing notes or award emojis with empty widget on work item updates - if ( - (incomingWidget?.type === WIDGET_TYPE_NOTES || - incomingWidget?.type === WIDGET_TYPE_AWARD_EMOJI) && - !context.variables.pageSize - ) { - return existingWidget; - } // we want to concat next page of awardEmoji to the existing ones if (incomingWidget?.type === WIDGET_TYPE_AWARD_EMOJI && context.variables.after) { @@ -116,7 +124,7 @@ export const config = { }; } - return incomingWidget || existingWidget; + return { ...existingWidget, ...incomingWidget }; }); }, }, @@ -211,6 +219,16 @@ export const config = { epicBoardList: { keyArgs: ['id'], }, + isShowingLabels: { + read(currentState) { + return currentState ?? true; + }, + }, + selectedBoardItems: { + read(currentState) { + return currentState ?? []; + }, + }, }, }, } @@ -235,6 +253,21 @@ export const resolvers = { }); return boardItem; }, + setSelectedBoardItems(_, { itemId }, { cache }) { + const sourceData = cache.readQuery({ query: selectedBoardItemsQuery }); + cache.writeQuery({ + query: selectedBoardItemsQuery, + data: { selectedBoardItems: [...sourceData.selectedBoardItems, itemId] }, + }); + return [...sourceData.selectedBoardItems, itemId]; + }, + unsetSelectedBoardItems(_, _variables, { cache }) { + cache.writeQuery({ + query: selectedBoardItemsQuery, + data: { selectedBoardItems: [] }, + }); + return []; + }, setError(_, { error }, { cache }) { cache.writeQuery({ query: errorQuery, @@ -258,6 +291,13 @@ export const resolvers = { }, }; }, + setIsShowingLabels(_, { isShowingLabels }, { cache }) { + cache.writeQuery({ + query: isShowingLabelsQuery, + data: { isShowingLabels }, + }); + return isShowingLabels; + }, }, }; diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json index 37c1674cc5a..4e0b1413f71 100644 --- a/app/assets/javascripts/graphql_shared/possible_types.json +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -3,6 +3,9 @@ "AlertManagementHttpIntegration", "AlertManagementPrometheusIntegration" ], + "AmazonS3ConfigurationInterface": [ + "AmazonS3ConfigurationType" + ], "BaseHeaderInterface": [ "AuditEventStreamingHeader", "AuditEventsStreamingInstanceHeader" diff --git a/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql index 7c88e494a2e..8f45224338f 100644 --- a/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql @@ -1,10 +1,21 @@ #import "../fragments/user.fragment.graphql" #import "~/graphql_shared/fragments/user_availability.fragment.graphql" -query groupUsersSearch($search: String!, $fullPath: ID!) { +query groupUsersSearch($search: String!, $fullPath: ID!, $after: String, $first: Int) { workspace: group(fullPath: $fullPath) { id - users: groupMembers(search: $search, relations: [DIRECT, DESCENDANTS, INHERITED]) { + users: groupMembers( + search: $search + relations: [DIRECT, DESCENDANTS, INHERITED] + first: $first + after: $after + sort: USER_FULL_NAME_ASC + ) { + pageInfo { + hasNextPage + endCursor + startCursor + } nodes { id user { diff --git a/app/assets/javascripts/groups/components/empty_states/groups_dashboard_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/groups_dashboard_empty_state.vue index 470ff45f47a..e74d827af9b 100644 --- a/app/assets/javascripts/groups/components/empty_states/groups_dashboard_empty_state.vue +++ b/app/assets/javascripts/groups/components/empty_states/groups_dashboard_empty_state.vue @@ -20,5 +20,6 @@ export default { :title="$options.i18n.title" :description="$options.i18n.description" :svg-path="groupsEmptyStateIllustration" + :svg-height="null" /> </template> diff --git a/app/assets/javascripts/groups/components/empty_states/groups_explore_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/groups_explore_empty_state.vue index f524b769802..0068772ff23 100644 --- a/app/assets/javascripts/groups/components/empty_states/groups_explore_empty_state.vue +++ b/app/assets/javascripts/groups/components/empty_states/groups_explore_empty_state.vue @@ -13,5 +13,9 @@ export default { </script> <template> - <gl-empty-state :title="$options.i18n.title" :svg-path="groupsEmptyStateIllustration" /> + <gl-empty-state + :title="$options.i18n.title" + :svg-path="groupsEmptyStateIllustration" + :svg-height="null" + /> </template> diff --git a/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue index 0bd95d59022..841a80b6ce4 100644 --- a/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue +++ b/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue @@ -86,6 +86,7 @@ export default { v-else :title="$options.i18n.withoutLinks.title" :svg-path="emptySubgroupIllustration" + :svg-height="null" :description="$options.i18n.withoutLinks.description" /> </template> 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 fd633df3022..853fdd7c55e 100644 --- a/app/assets/javascripts/groups/components/group_name_and_path.vue +++ b/app/assets/javascripts/groups/components/group_name_and_path.vue @@ -294,7 +294,7 @@ export default { :name="fields.name.name" :placeholder="$options.i18n.inputs.name.placeholder" data-testid="group-name-field" - :size="$options.inputSize" + :width="$options.inputSize" :state="nameFeedbackState" @invalid="handleInvalidName" /> @@ -374,7 +374,7 @@ export default { :maxlength="fields.path.maxLength" :pattern="fields.path.pattern" :state="pathFeedbackState" - :size="pathInputSize" + :width="pathInputSize" required data-testid="group-path-field" :data-bind-in="mattermostEnabled ? $options.mattermostDataBindName : null" @@ -397,7 +397,7 @@ export default { :id="fields.groupId.id" :value="fields.groupId.value" :name="fields.groupId.name" - size="sm" + width="sm" readonly /> </gl-form-group> diff --git a/app/assets/javascripts/groups/components/transfer_group_form.vue b/app/assets/javascripts/groups/components/transfer_group_form.vue index 3da417ebf0a..9f968817a3a 100644 --- a/app/assets/javascripts/groups/components/transfer_group_form.vue +++ b/app/assets/javascripts/groups/components/transfer_group_form.vue @@ -73,7 +73,7 @@ export default { :disabled="disableSubmitButton" :phrase="confirmationPhrase" :button-text="confirmButtonText" - button-qa-selector="transfer_group_button" + button-testid="transfer-group-button" @confirm="$emit('confirm')" /> </div> diff --git a/app/assets/javascripts/helpers/startup_css_helper.js b/app/assets/javascripts/helpers/startup_css_helper.js deleted file mode 100644 index 6e19979721c..00000000000 --- a/app/assets/javascripts/helpers/startup_css_helper.js +++ /dev/null @@ -1,36 +0,0 @@ -const CSS_LOADED_EVENT = 'CSSLoaded'; -const STARTUP_LINK_LOADED_EVENT = 'CSSStartupLinkLoaded'; - -const getAllStartupLinks = (() => { - let links = null; - return () => { - if (!links) { - links = Array.from(document.querySelectorAll('link[data-startupcss]')); - } - return links; - }; -})(); -const isStartupLinkLoaded = ({ dataset }) => dataset.startupcss === 'loaded'; -const allLinksLoaded = () => getAllStartupLinks().every(isStartupLinkLoaded); - -const handleStartupEvents = () => { - if (allLinksLoaded()) { - document.dispatchEvent(new CustomEvent(CSS_LOADED_EVENT)); - document.removeEventListener(STARTUP_LINK_LOADED_EVENT, handleStartupEvents); - } -}; - -/* For `waitForCSSLoaded` methods, see docs.gitlab.com/ee/development/fe_guide/performance.html#important-considerations */ -export const waitForCSSLoaded = (action = () => {}) => { - if (allLinksLoaded()) { - return new Promise((resolve) => { - action(); - resolve(); - }); - } - - return new Promise((resolve) => { - document.addEventListener(CSS_LOADED_EVENT, resolve, { once: true }); - document.addEventListener(STARTUP_LINK_LOADED_EVENT, handleStartupEvents); - }).then(action); -}; diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index 741845e3325..ba1258f8b50 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -176,7 +176,6 @@ export default { type="text" class="form-control" data-testid="file-name-field" - data-qa-selector="file_name_field" :placeholder="placeholder" /> </form> diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js index 2e113003f8a..868830c953a 100644 --- a/app/assets/javascripts/ide/init_gitlab_web_ide.js +++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js @@ -1,9 +1,11 @@ import { start } from '@gitlab/web-ide'; import { __ } from '~/locale'; import { cleanLeadingSeparator } from '~/lib/utils/url_utility'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; 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 Tracking from '~/tracking'; 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'; @@ -39,13 +41,14 @@ export const initGitlabWebIDE = async (el) => { filePath, mergeRequest: mrId, forkInfo: forkInfoJSON, - editorFontSrcUrl, - editorFontFormat, - editorFontFamily, + editorFont: editorFontJSON, codeSuggestionsEnabled, } = el.dataset; const rootEl = setupRootElement(el); + const editorFont = editorFontJSON + ? convertObjectPropsToCamelCase(JSON.parse(editorFontJSON), { deep: true }) + : null; const forkInfo = forkInfoJSON ? JSON.parse(forkInfoJSON) : null; // See ClientOnlyConfig https://gitlab.com/gitlab-org/gitlab-web-ide/-/blob/main/packages/web-ide-types/src/config.ts#L17 @@ -69,13 +72,11 @@ export const initGitlabWebIDE = async (el) => { userPreferences: el.dataset.userPreferencesPath, signIn: el.dataset.signInPath, }, - editorFont: { - srcUrl: editorFontSrcUrl, - fontFamily: editorFontFamily, - format: editorFontFormat, - }, + editorFont, codeSuggestionsEnabled, handleTracking, + // See https://gitlab.com/gitlab-org/gitlab-web-ide/-/blob/main/packages/web-ide-types/src/config.ts#L86 + telemetryEnabled: Tracking.enabled(), 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.'), diff --git a/app/assets/javascripts/ide/lib/gitlab_web_ide/handle_tracking_event.js b/app/assets/javascripts/ide/lib/gitlab_web_ide/handle_tracking_event.js index 615dad02386..5e3e5bfe4c1 100644 --- a/app/assets/javascripts/ide/lib/gitlab_web_ide/handle_tracking_event.js +++ b/app/assets/javascripts/ide/lib/gitlab_web_ide/handle_tracking_event.js @@ -8,7 +8,7 @@ export const handleTracking = ({ name, data }) => { if (data && Object.keys(data).length) { Tracking.event(undefined, snakeCaseEventName, { /* See GitLab snowplow schema for a definition of the extra field - * https://docs.gitlab.com/ee/development/snowplow/schemas.html#gitlab_standard. + * https://gitlab.com/gitlab-org/iglu/-/blob/master/public/schemas/com.gitlab/gitlab_standard/jsonschema/1-0-9. */ extra: convertObjectPropsToSnakeCase(data, { deep: true, diff --git a/app/assets/javascripts/import/constants.js b/app/assets/javascripts/import/constants.js index b9814b5ca60..ddf69a8fcdf 100644 --- a/app/assets/javascripts/import/constants.js +++ b/app/assets/javascripts/import/constants.js @@ -8,7 +8,7 @@ const STATISTIC_ITEMS = { issue_event: __('Issue events'), label: __('Labels'), lfs_object: __('LFS objects'), - merge_request_attachment: s__('GithubImporter|Merge request links'), + merge_request_attachment: s__('GithubImporter|PR attachments'), milestone: __('Milestones'), note: __('Notes'), note_attachment: s__('GithubImporter|Note links'), @@ -17,7 +17,7 @@ const STATISTIC_ITEMS = { pull_request: s__('GithubImporter|Pull requests'), pull_request_merged_by: s__('GithubImporter|PR mergers'), pull_request_review: s__('GithubImporter|PR reviews'), - pull_request_review_request: s__('GithubImporter|PR reviews'), + pull_request_review_request: s__('GithubImporter|PR reviewers'), release: __('Releases'), release_attachment: s__('GithubImporter|Release links'), }; diff --git a/app/assets/javascripts/import_entities/components/group_dropdown.vue b/app/assets/javascripts/import_entities/components/group_dropdown.vue deleted file mode 100644 index 68bdcf7ef90..00000000000 --- a/app/assets/javascripts/import_entities/components/group_dropdown.vue +++ /dev/null @@ -1,72 +0,0 @@ -<script> -import { GlDropdown, GlSearchBoxByType } from '@gitlab/ui'; -import { debounce } from 'lodash'; - -import { s__ } from '~/locale'; -import { createAlert } from '~/alert'; -import searchNamespacesWhereUserCanImportProjectsQuery from '~/import_entities/import_projects/graphql/queries/search_namespaces_where_user_can_import_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'; - -// This is added outside the component as each dropdown on the page triggers a query, -// so if multiple queries fail, we only show 1 error. -const reportNamespaceLoadError = debounce( - () => - createAlert({ - message: s__('ImportProjects|Requesting namespaces failed'), - }), - DEFAULT_DEBOUNCE_AND_THROTTLE_MS, -); - -export default { - components: { - GlDropdown, - GlSearchBoxByType, - }, - inheritAttrs: false, - data() { - return { searchTerm: '' }; - }, - apollo: { - namespaces: { - query: searchNamespacesWhereUserCanImportProjectsQuery, - 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) => - ns.fullPath.toLowerCase().includes(this.searchTerm.toLowerCase()), - ); - }, - }, -}; -</script> -<template> - <gl-dropdown - toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" - class="gl-h-7 gl-flex-fill-1" - data-qa-selector="target_namespace_selector_dropdown" - v-bind="$attrs" - > - <template #header> - <gl-search-box-by-type v-model.trim="searchTerm" /> - </template> - <slot :namespaces="filteredNamespaces"></slot> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/import_entities/components/import_target_dropdown.vue b/app/assets/javascripts/import_entities/components/import_target_dropdown.vue index b18a106608a..47c030bf1fc 100644 --- a/app/assets/javascripts/import_entities/components/import_target_dropdown.vue +++ b/app/assets/javascripts/import_entities/components/import_target_dropdown.vue @@ -26,13 +26,19 @@ export default { }, props: { + disabled: { + type: Boolean, + required: false, + default: false, + }, selected: { type: String, required: true, }, userNamespace: { type: String, - required: true, + required: false, + default: undefined, }, }, @@ -66,6 +72,10 @@ export default { }, computed: { + isProject() { + return Boolean(this.userNamespace); + }, + filteredNamespaces() { return (this.namespaces ?? []).filter((ns) => ns.fullPath.toLowerCase().includes(this.searchTerm.toLowerCase()), @@ -78,14 +88,33 @@ export default { items() { return [ - { - text: __('Users'), - options: [{ text: this.userNamespace, value: this.userNamespace }], - }, + this.isProject + ? { + text: __('Users'), + options: [ + { + text: this.userNamespace, + value: this.userNamespace, + }, + ], + } + : { + text: __('Parent'), + textSrOnly: true, + options: [ + { + text: s__('BulkImport|No parent'), + value: '', + }, + ], + }, { text: __('Groups'), options: this.filteredNamespaces.map((namespace) => { - return { text: namespace.fullPath, value: namespace.fullPath }; + return { + text: namespace.fullPath, + value: namespace.fullPath, + }; }), }, ]; @@ -94,7 +123,15 @@ export default { methods: { onSelect(value) { - this.$emit('select', value); + if (this.isProject) { + this.$emit('select', value); + } else if (value === '') { + this.$emit('select', { fullPath: '', id: null }); + } else { + const { fullPath, id } = this.filteredNamespaces.find((ns) => ns.fullPath === value); + + this.$emit('select', { fullPath, id }); + } }, onSearch(value) { @@ -107,12 +144,13 @@ export default { <template> <gl-collapsible-listbox :items="items" + :disabled="disabled" :selected="selected" :toggle-text="toggleText" searchable fluid-width toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" - data-qa-selector="target_namespace_selector_dropdown" + data-testid="target-namespace-dropdown" @select="onSelect" @search="onSearch" /> 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 cd07e9fbdd9..24197c680eb 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 @@ -393,7 +393,7 @@ export default { } }, - importGroup({ group, extraArgs, index }) { + async importGroup({ group, extraArgs, index }) { if (group.flags.isFinished && !this.reimportRequests.includes(group.id)) { this.validateImportTarget(group.importTarget); this.reimportRequests.push(group.id); @@ -402,7 +402,7 @@ export default { }); } else { this.reimportRequests = this.reimportRequests.filter((id) => id !== group.id); - this.requestGroupsImport([ + await this.requestGroupsImport([ { sourceGroupId: group.id, targetNamespace: group.importTarget.targetNamespace.fullPath, @@ -410,6 +410,16 @@ export default { ...extraArgs, }, ]); + + const updatedGroup = this.groups?.find((g) => g.id === group.id); + + if ( + updatedGroup.progress && + updatedGroup.progress.status === STATUSES.FAILED && + updatedGroup.progress.message + ) { + this.reimportRequests.push(group.id); + } } }, @@ -427,6 +437,7 @@ export default { }, setPageSize(size) { + this.page = 1; this.perPage = size; }, 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 807b084fefb..b4484c89b9f 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 @@ -1,20 +1,12 @@ <script> -import { - GlDropdownDivider, - GlDropdownItem, - GlDropdownSectionHeader, - GlFormInput, -} from '@gitlab/ui'; -import { s__ } from '~/locale'; -import ImportGroupDropdown from '../../components/group_dropdown.vue'; +import { GlFormInput } from '@gitlab/ui'; +import ImportTargetDropdown from '../../components/import_target_dropdown.vue'; + import { getInvalidNameValidationMessage } from '../utils'; export default { components: { - ImportGroupDropdown, - GlDropdownDivider, - GlDropdownItem, - GlDropdownSectionHeader, + ImportTargetDropdown, GlFormInput, }, props: { @@ -25,8 +17,8 @@ export default { }, computed: { - fullPath() { - return this.group.importTarget.targetNamespace.fullPath || s__('BulkImport|No parent'); + selectedImportTarget() { + return this.group.importTarget.targetNamespace.fullPath || ''; }, validationMessage() { return ( @@ -47,6 +39,10 @@ export default { focusNewName() { this.$refs.newName.$el.focus(); }, + + onImportTargetSelect(namespace) { + this.$emit('update-target-namespace', namespace); + }, }, }; </script> @@ -54,34 +50,12 @@ export default { <template> <div> <div class="gl-display-flex gl-align-items-stretch"> - <import-group-dropdown - #default="{ namespaces }" - :text="fullPath" + <import-target-dropdown + :selected="selectedImportTarget" :disabled="!isPathSelectionAvailable" - 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" - data-testid="target-namespace-selector" - > - <gl-dropdown-item @click="$emit('update-target-namespace', { fullPath: '', id: null })">{{ - s__('BulkImport|No parent') - }}</gl-dropdown-item> - <template v-if="namespaces.length"> - <gl-dropdown-divider /> - <gl-dropdown-section-header> - {{ s__('BulkImport|Existing groups') }} - </gl-dropdown-section-header> - <gl-dropdown-item - v-for="ns in namespaces" - :key="ns.fullPath" - data-qa-selector="target_group_dropdown_item" - :data-qa-group-name="ns.fullPath" - @click="$emit('update-target-namespace', ns)" - > - {{ ns.fullPath }} - </gl-dropdown-item> - </template> - </import-group-dropdown> + @select="onImportTargetSelect" + /> + <div class="gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1 gl-bg-gray-10" :class="{ @@ -100,6 +74,7 @@ export default { 'gl-inset-border-1-gray-100!': !isPathSelectionAvailable, }" debounce="500" + data-testid="target-namespace-input" :disabled="!isPathSelectionAvailable" :value="group.importTarget.newName" :aria-label="__('New name')" diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index 782f417a989..0e1afebbe2b 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -500,6 +500,7 @@ export default { <gl-empty-state :title="emptyStateData.title" :svg-path="emptyListSvgPath" + :svg-height="null" :description="emptyStateData.description" :primary-button-link="emptyStateData.btnLink" :primary-button-text="emptyStateData.btnText" diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js index e803e11bf6d..12f5fa21137 100644 --- a/app/assets/javascripts/integrations/constants.js +++ b/app/assets/javascripts/integrations/constants.js @@ -59,6 +59,8 @@ export const integrationTriggerEvents = { DEPLOYMENT: 'deployment_events', ALERT: 'alert_events', INCIDENT: 'incident_events', + GROUP_MENTION: 'group_mention_events', + GROUP_CONFIDENTIAL_MENTION: 'group_confidential_mention_events', }; export const integrationTriggerEventTitles = { @@ -88,6 +90,12 @@ export const integrationTriggerEventTitles = { [integrationTriggerEvents.INCIDENT]: s__( 'IntegrationEvents|An incident is created, closed, or reopened', ), + [integrationTriggerEvents.GROUP_MENTION]: s__( + 'IntegrationEvents|A group is mentioned in a public context', + ), + [integrationTriggerEvents.GROUP_CONFIDENTIAL_MENTION]: s__( + 'IntegrationEvents|A group is mentioned in a confidential context', + ), }; export const billingPlans = { diff --git a/app/assets/javascripts/integrations/gitlab_slack_application/components/gitlab_slack_application.vue b/app/assets/javascripts/integrations/gitlab_slack_application/components/gitlab_slack_application.vue index bcb199853bd..edfb0af5bbe 100644 --- a/app/assets/javascripts/integrations/gitlab_slack_application/components/gitlab_slack_application.vue +++ b/app/assets/javascripts/integrations/gitlab_slack_application/components/gitlab_slack_application.vue @@ -101,7 +101,7 @@ export default { @project-selected="selectProject" /> - <div class="gl-display-flex gl-justify-content-end"> + <div class="gl-display-flex gl-justify-content-end gl-mt-3"> <gl-button category="primary" variant="confirm" diff --git a/app/assets/javascripts/integrations/gitlab_slack_application/components/projects_dropdown.vue b/app/assets/javascripts/integrations/gitlab_slack_application/components/projects_dropdown.vue index 26d191cd0bf..7c5287c69d6 100644 --- a/app/assets/javascripts/integrations/gitlab_slack_application/components/projects_dropdown.vue +++ b/app/assets/javascripts/integrations/gitlab_slack_application/components/projects_dropdown.vue @@ -1,13 +1,13 @@ <script> -import { GlDropdown } from '@gitlab/ui'; +import { GlCollapsibleListbox, GlAvatarLabeled } from '@gitlab/ui'; import { __ } from '~/locale'; - -import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue'; +import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; export default { components: { - GlDropdown, - ProjectListItem, + GlCollapsibleListbox, + GlAvatarLabeled, }, props: { projectDropdownText: { @@ -26,30 +26,61 @@ export default { default: null, }, }, + data() { + return { + selected: this.selectedProject, + }; + }, computed: { dropdownText() { return this.selectedProject ? this.selectedProject.name_with_namespace : this.projectDropdownText; }, + items() { + const items = this.projects.map((project) => { + return { + value: project.id, + ...project, + }; + }); + + return convertObjectPropsToCamelCase(items, { deep: true }); + }, }, methods: { - onClick(project) { - this.$emit('project-selected', project); - this.$refs.dropdown.hide(true); + getEntityId(project) { + return isGid(project.id) ? getIdFromGraphQLId(project.id) : project.id; + }, + selectProject(projectId) { + this.$emit( + 'project-selected', + this.projects.find((project) => project.id === projectId), + ); }, }, }; </script> <template> - <gl-dropdown ref="dropdown" block :text="dropdownText" menu-class="gl-w-full!"> - <project-list-item - v-for="project in projects" - :key="project.id" - :project="project" - :selected="false" - @click="onClick(project)" - /> - </gl-dropdown> + <gl-collapsible-listbox + v-model="selected" + block + fluid-width + is-check-centered + :toggle-text="dropdownText" + :items="items" + @select="selectProject" + > + <template #list-item="{ item }"> + <gl-avatar-labeled + :label="item.nameWithNamespace" + :entity-name="item.nameWithNamespace" + :entity-id="getEntityId(item)" + shape="rect" + :size="32" + :src="item.avatarUrl" + /> + </template> + </gl-collapsible-listbox> </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 91dbd86418c..4b492e48095 100644 --- a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue @@ -16,7 +16,7 @@ import GroupSelect from './group_select.vue'; import InviteGroupNotification from './invite_group_notification.vue'; export default { - name: 'InviteMembersModal', + name: 'InviteGroupsModal', components: { GroupSelect, InviteModalBase, 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 e9d7acdc913..509efd31dcd 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -1,12 +1,14 @@ <script> import { GlAlert, GlButton, GlCollapse, GlIcon } from '@gitlab/ui'; import { partition, isString, uniqueId, isEmpty } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue'; import Api from '~/api'; import Tracking from '~/tracking'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { n__, sprintf } from '~/locale'; import { memberName, triggerExternalAlert } from 'ee_else_ce/invite_members/utils/member_utils'; +import { captureException } from '~/ci/runner/sentry_utils'; import { USERS_FILTER_ALL, MEMBER_MODAL_LABELS, @@ -37,6 +39,9 @@ export default { ActiveTrialNotification: () => import('ee_component/invite_members/components/active_trial_notification.vue'), }, + directives: { + SafeHtml, + }, mixins: [Tracking.mixin({ category: INVITE_MEMBER_MODAL_TRACKING_CATEGORY })], props: { id: { @@ -262,8 +267,9 @@ export default { } else { this.onInviteSuccess(); } - } catch (e) { - this.showInvalidFeedbackMessage(e); + } catch (error) { + captureException({ error, component: this.$options.name }); + this.showInvalidFeedbackMessage(error); } finally { this.isLoading = false; } @@ -391,7 +397,8 @@ export default { :key="error.member" data-testid="errors-limited-item" > - <strong>{{ error.displayedMemberName }}:</strong> {{ error.message }} + <strong>{{ error.displayedMemberName }}:</strong> + <span v-safe-html="error.message"></span> </li> </ul> <template v-if="shouldErrorsSectionExpand"> @@ -402,7 +409,8 @@ export default { :key="error.member" data-testid="errors-expanded-item" > - <strong>{{ error.displayedMemberName }}:</strong> {{ error.message }} + <strong>{{ error.displayedMemberName }}:</strong> + <span v-safe-html="error.message"></span> </li> </ul> </gl-collapse> 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 5a891e23faf..18d22395104 100644 --- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue +++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue @@ -253,7 +253,6 @@ export default { <gl-modal ref="modal" :modal-id="modalId" - data-qa-selector="invite_members_modal_content" data-testid="invite-modal" size="sm" dialog-class="gl-mx-5" diff --git a/app/assets/javascripts/issuable/components/hidden_badge.vue b/app/assets/javascripts/issuable/components/hidden_badge.vue new file mode 100644 index 00000000000..a80dc2f62d4 --- /dev/null +++ b/app/assets/javascripts/issuable/components/hidden_badge.vue @@ -0,0 +1,36 @@ +<script> +import { GlBadge, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { issuableTypeText } from '~/issues/constants'; +import { __, sprintf } from '~/locale'; + +export default { + components: { + GlBadge, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + issuableType: { + type: String, + required: false, + default: '', + }, + }, + computed: { + title() { + return sprintf(__('This %{issuable} is hidden because its author has been banned.'), { + issuable: issuableTypeText[this.issuableType], + }); + }, + }, +}; +</script> + +<template> + <gl-badge v-gl-tooltip :title="title" variant="warning"> + <gl-icon name="spam" /> + <span class="gl-sr-only">{{ __('Hidden') }}</span> + </gl-badge> +</template> diff --git a/app/assets/javascripts/issuable/components/locked_badge.vue b/app/assets/javascripts/issuable/components/locked_badge.vue new file mode 100644 index 00000000000..f97ac888417 --- /dev/null +++ b/app/assets/javascripts/issuable/components/locked_badge.vue @@ -0,0 +1,36 @@ +<script> +import { GlBadge, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { issuableTypeText } from '~/issues/constants'; +import { __, sprintf } from '~/locale'; + +export default { + components: { + GlBadge, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + issuableType: { + type: String, + required: false, + default: '', + }, + }, + computed: { + title() { + return sprintf(__('This %{issuable} is locked. Only project members can comment.'), { + issuable: issuableTypeText[this.issuableType], + }); + }, + }, +}; +</script> + +<template> + <gl-badge v-gl-tooltip :title="title" variant="warning"> + <gl-icon name="lock" /> + <span class="gl-sr-only">{{ __('Locked') }}</span> + </gl-badge> +</template> diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue index ff48bfceb29..71bd301162e 100644 --- a/app/assets/javascripts/issuable/components/related_issuable_item.vue +++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue @@ -248,7 +248,7 @@ export default { size="small" :disabled="removeDisabled" class="js-issue-item-remove-button gl-mr-2" - data-testid="remove_related_issue_button" + data-testid="remove-related-issue-button" :title="__('Remove')" :aria-label="__('Remove')" @click="onRemoveRequest" diff --git a/app/assets/javascripts/issuable/issuable_label_selector.js b/app/assets/javascripts/issuable/issuable_label_selector.js index fc6d850c341..804f7384732 100644 --- a/app/assets/javascripts/issuable/issuable_label_selector.js +++ b/app/assets/javascripts/issuable/issuable_label_selector.js @@ -45,7 +45,7 @@ export default () => { labelsManagePath, variant: VARIANT_EMBEDDED, workspaceType: WORKSPACE_PROJECT, - toggleAttrs: { 'data-testid': 'issuable_label_dropdown' }, + toggleAttrs: { 'data-testid': 'issuable-label-dropdown' }, }, render(createElement) { return createElement(IssuableLabelSelector); diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js index 80344efc44c..3d8017e6e07 100644 --- a/app/assets/javascripts/issues/constants.js +++ b/app/assets/javascripts/issues/constants.js @@ -28,7 +28,7 @@ export const issuableStatusText = { [STATUS_LOCKED]: __('Open'), }; -export const IssuableTypeText = { +export const issuableTypeText = { [TYPE_ISSUE]: __('issue'), [TYPE_EPIC]: __('epic'), [TYPE_MERGE_REQUEST]: __('merge request'), diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js index 3bd28c50800..eea5207801c 100644 --- a/app/assets/javascripts/issues/index.js +++ b/app/assets/javascripts/issues/index.js @@ -17,17 +17,6 @@ import initSidebarBundle from '~/sidebar/sidebar_bundle'; import initWorkItemLinks from '~/work_items/components/work_item_links'; import ZenMode from '~/zen_mode'; import initAwardsApp from '~/emoji/awards_app'; -import FilteredSearchServiceDesk from './filtered_search_service_desk'; - -export function initFilteredSearchServiceDesk() { - if (document.querySelector('.filtered-search')) { - const supportBotData = JSON.parse( - document.querySelector('.js-service-desk-issues').dataset.supportBot, - ); - const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData); - filteredSearchManager.setup(); - } -} export function initForm() { new IssuableForm($('.issue-form')); // eslint-disable-line no-new 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 index 3c58843bcbc..a9ad2db5dd3 100644 --- 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 @@ -29,6 +29,7 @@ export default { :title="$options.i18n.noSearchResultsTitle" :svg-path="emptyStateSvgPath" :svg-height="150" + data-testid="issuable-empty-state" > <template #actions> <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> @@ -42,6 +43,8 @@ export default { :description="$options.i18n.noOpenIssuesDescription" :title="$options.i18n.noOpenIssuesTitle" :svg-path="emptyStateSvgPath" + :svg-height="null" + data-testid="issuable-empty-state" > <template #actions> <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> @@ -55,5 +58,6 @@ export default { :title="$options.i18n.noClosedIssuesTitle" :svg-path="emptyStateSvgPath" :svg-height="150" + data-testid="issuable-empty-state" /> </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 index 3d62ea07f59..6741b39d5ef 100644 --- 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 @@ -61,6 +61,7 @@ export default { :title="$options.i18n.noIssuesTitle" :svg-path="emptyStateSvgPath" :svg-height="150" + data-testid="issuable-empty-state" > <template #description> <gl-link :href="$options.issuesHelpPagePath"> @@ -71,16 +72,26 @@ export default { </p> </template> <template #actions> - <gl-button v-if="canCreateProjects" :href="newProjectPath" variant="confirm"> + <gl-button + v-if="canCreateProjects" + :href="newProjectPath" + variant="confirm" + class="gl-mx-2 gl-mb-3" + > {{ $options.i18n.newProjectLabel }} </gl-button> - <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + <gl-button + v-if="showNewIssueLink" + :href="newIssuePath" + variant="confirm" + class="gl-mx-2 gl-mb-3" + > {{ $options.i18n.newIssueLabel }} </gl-button> <gl-disclosure-dropdown v-if="showCsvButtons" - class="gl-w-full gl-sm-w-auto gl-sm-mr-3" + class="gl-mx-2 gl-mb-3" :toggle-text="$options.i18n.importIssues" data-testid="import-issues-dropdown" > @@ -92,7 +103,7 @@ export default { <new-resource-dropdown v-if="showNewIssueDropdown" - class="gl-align-self-center" + class="gl-align-self-center gl-mx-2 gl-mb-3" :query="$options.searchProjectsQuery" :query-variables="newIssueDropdownQueryVariables" :extract-projects="extractProjects" @@ -120,8 +131,10 @@ export default { v-else :title="$options.i18n.noIssuesTitle" :svg-path="emptyStateSvgPath" + :svg-height="null" :primary-button-text="$options.i18n.noIssuesSignedOutButtonText" :primary-button-link="signInPath" + data-testid="issuable-empty-state" > <template #description> <gl-link :href="$options.issuesHelpPagePath"> 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 3d8ed3af816..16e687cff10 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -21,7 +21,6 @@ import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_coun import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { createAlert, VARIANT_INFO } from '~/alert'; import { TYPENAME_USER } from '~/graphql_shared/constants'; -import usersAutocompleteQuery from '~/graphql_shared/queries/users_autocomplete.query.graphql'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; @@ -384,7 +383,8 @@ export default { dataType: 'user', defaultUsers: [], operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT, - fetchUsers: this.fetchUsers, + fullPath: this.fullPath, + isProject: this.isProject, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-author`, preloadedUsers, }, @@ -395,7 +395,8 @@ export default { token: UserToken, dataType: 'user', operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT, - fetchUsers: this.fetchUsers, + fullPath: this.fullPath, + isProject: this.isProject, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-assignee`, preloadedUsers, }, @@ -634,14 +635,6 @@ export default { fetchLatestLabels(search) { return this.fetchLabelsWithFetchPolicy(search, fetchPolicies.NETWORK_ONLY); }, - fetchUsers(search) { - return this.$apollo - .query({ - query: usersAutocompleteQuery, - variables: { fullPath: this.fullPath, search, isProject: this.isProject }, - }) - .then(({ data }) => data[this.namespace]?.autocompleteUsers); - }, getExportCsvPathWithQuery() { return `${this.exportCsvPath}${window.location.search}`; }, diff --git a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql index f3173f0e33a..3b49c0efb14 100644 --- a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql +++ b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql @@ -11,7 +11,6 @@ fragment IssueFragment on Issue { moved state title - titleHtml updatedAt closedAt upvotes diff --git a/app/assets/javascripts/issues/service_desk/components/empty_state_with_any_issues.vue b/app/assets/javascripts/issues/service_desk/components/empty_state_with_any_issues.vue index ab9e70ae223..f5f06e4daef 100644 --- a/app/assets/javascripts/issues/service_desk/components/empty_state_with_any_issues.vue +++ b/app/assets/javascripts/issues/service_desk/components/empty_state_with_any_issues.vue @@ -55,5 +55,6 @@ export default { :title="content.title" :svg-path="emptyStateSvgPath" :svg-height="content.svgHeight" + data-testid="issuable-empty-state" /> </template> diff --git a/app/assets/javascripts/issues/service_desk/components/empty_state_without_any_issues.vue b/app/assets/javascripts/issues/service_desk/components/empty_state_without_any_issues.vue index 9dbed2c2579..ea866dfb161 100644 --- a/app/assets/javascripts/issues/service_desk/components/empty_state_without_any_issues.vue +++ b/app/assets/javascripts/issues/service_desk/components/empty_state_without_any_issues.vue @@ -42,7 +42,9 @@ export default { <gl-empty-state :title="$options.i18n.infoBannerTitle" :svg-path="emptyStateSvgPath" + :svg-height="null" content-class="gl-max-w-80!" + data-testid="issues-service-desk-empty-state" > <template #description> <p v-if="canSeeEmailAddress"> @@ -60,9 +62,11 @@ export default { v-else :title="$options.i18n.infoBannerTitle" :svg-path="emptyStateSvgPath" + :svg-height="null" :primary-button-text="$options.i18n.noIssuesSignedOutButtonText" :primary-button-link="signInPath" content-class="gl-max-w-80!" + data-testid="issues-service-desk-empty-state" > <template #description> <p>{{ $options.i18n.infoBannerUserNote }}</p> diff --git a/app/assets/javascripts/issues/filtered_search_service_desk.js b/app/assets/javascripts/issues/service_desk/filtered_search_service_desk.js index bec207aa439..bec207aa439 100644 --- a/app/assets/javascripts/issues/filtered_search_service_desk.js +++ b/app/assets/javascripts/issues/service_desk/filtered_search_service_desk.js diff --git a/app/assets/javascripts/issues/service_desk/index.js b/app/assets/javascripts/issues/service_desk/index.js index 579cf343477..cc5f6b40a91 100644 --- a/app/assets/javascripts/issues/service_desk/index.js +++ b/app/assets/javascripts/issues/service_desk/index.js @@ -3,8 +3,19 @@ import VueApollo from 'vue-apollo'; import VueRouter from 'vue-router'; import { parseBoolean } from '~/lib/utils/common_utils'; import ServiceDeskListApp from 'ee_else_ce/issues/service_desk/components/service_desk_list_app.vue'; +import FilteredSearchServiceDesk from './filtered_search_service_desk'; import { gqlClient } from './graphql'; +export function initFilteredSearchServiceDesk() { + if (document.querySelector('.filtered-search')) { + const supportBotData = JSON.parse( + document.querySelector('.js-service-desk-issues').dataset.supportBot, + ); + const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData); + filteredSearchManager.setup(); + } +} + export async function mountServiceDeskListApp() { const el = document.querySelector('.js-service-desk-list'); diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index d59692d2a28..756585683c8 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -185,12 +185,12 @@ export default { default: false, }, issueId: { - type: Number, + type: String, required: false, default: null, }, issueIid: { - type: Number, + type: String, required: false, default: null, }, @@ -521,7 +521,6 @@ export default { :project-namespace="projectNamespace" :can-attach-file="canAttachFile" :enable-autocomplete="enableAutocomplete" - :issue-id="issueId" :issuable-type="issuableType" @updateForm="setFormState" /> @@ -550,7 +549,6 @@ export default { :issuable-type="issuableType" :show="isStickyHeaderShowing" :title="state.titleText" - :title-html="state.titleHtml" @hide="hideStickyHeader" @show="showStickyHeader" /> diff --git a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue index 26e82f10c3d..23979669453 100644 --- a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue +++ b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue @@ -35,7 +35,7 @@ export default { return { attributes: { variant: 'danger', - 'data-qa-selector': 'confirm_delete_issue_button', + 'data-testid': 'confirm-delete-issue-button', }, text: this.title, }; diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index acbba216601..369aa694739 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -74,12 +74,12 @@ export default { default: 0, }, issueId: { - type: Number, + type: String, required: false, default: null, }, issueIid: { - type: Number, + type: String, required: false, default: null, }, @@ -362,7 +362,12 @@ export default { }, }, update: (cache, { data: { workItemCreate } }) => - addHierarchyChild(cache, this.fullPath, String(this.issueIid), workItemCreate.workItem), + addHierarchyChild({ + cache, + fullPath: this.fullPath, + iid: this.issueIid, + workItem: workItemCreate.workItem, + }), }); const { workItem, errors } = data.workItemCreate; @@ -392,7 +397,12 @@ export default { mutation: deleteWorkItemMutation, variables: { input: { id } }, update: (cache) => - removeHierarchyChild(cache, this.fullPath, String(this.issueIid), { id }), + removeHierarchyChild({ + cache, + fullPath: this.fullPath, + iid: this.issueIid, + workItem: { id }, + }), }); if (data.workItemDelete.errors?.length) { diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue index efe1619ed1f..10323b99665 100644 --- a/app/assets/javascripts/issues/show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue @@ -2,7 +2,6 @@ <script> import { __ } from '~/locale'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; -import glFeaturesFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking'; import { ISSUE_NOTEABLE_TYPE } from '~/notes/constants'; import updateMixin from '../../mixins/update'; @@ -11,7 +10,7 @@ export default { components: { MarkdownEditor, }, - mixins: [updateMixin, glFeaturesFlagMixin()], + mixins: [updateMixin], props: { value: { type: String, @@ -71,7 +70,6 @@ export default { <label class="sr-only" for="issue-description">{{ __('Description') }}</label> <markdown-editor ref="markdownEditor" - :enable-content-editor="Boolean(glFeatures.contentEditorOnIssues)" class="gl-mt-3" :value="value" :render-markdown-path="markdownPreviewPath" diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue index 047bdcdcefc..c2248d66860 100644 --- a/app/assets/javascripts/issues/show/components/form.vue +++ b/app/assets/javascripts/issues/show/components/form.vue @@ -2,10 +2,7 @@ <script> import { GlAlert } from '@gitlab/ui'; import { getDraft, updateDraft, getLockVersion, clearDraft } from '~/lib/utils/autosave'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { TYPENAME_ISSUE, TYPENAME_USER } from '~/graphql_shared/constants'; import { TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../event_hub'; import EditActions from './edit_actions.vue'; import DescriptionField from './fields/description.vue'; @@ -24,7 +21,6 @@ export default { IssuableTypeField, LockedWarning, }, - mixins: [glFeatureFlagMixin()], props: { endpoint: { type: String, @@ -78,11 +74,6 @@ export default { required: false, default: '', }, - issueId: { - type: Number, - required: false, - default: null, - }, }, data() { const autosaveKey = [document.location.pathname, document.location.search]; @@ -110,12 +101,6 @@ export default { showTypeField() { return [TYPE_INCIDENT, TYPE_ISSUE].includes(this.issuableType); }, - resourceId() { - return this.issueId && convertToGraphQLId(TYPENAME_ISSUE, this.issueId); - }, - userId() { - return convertToGraphQLId(TYPENAME_USER, gon.current_user_id); - }, }, watch: { formData: { diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index 81e5c30a264..dee4c536afa 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -14,21 +14,16 @@ import * as Sentry from '@sentry/browser'; import { mapActions, mapGetters, mapState } from 'vuex'; import { createAlert, VARIANT_SUCCESS } from '~/alert'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; -import { STATUS_CLOSED, TYPE_ISSUE, IssuableTypeText } from '~/issues/constants'; -import { - ISSUE_STATE_EVENT_CLOSE, - ISSUE_STATE_EVENT_REOPEN, - NEW_ACTIONS_POPOVER_KEY, -} from '~/issues/show/constants'; +import { STATUS_CLOSED, TYPE_ISSUE, issuableTypeText } from '~/issues/constants'; +import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; -import { getCookie, parseBoolean, setCookie, isLoggedIn } from '~/lib/utils/common_utils'; +import { isLoggedIn } from '~/lib/utils/common_utils'; import { visitUrl } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; import eventHub from '~/notes/event_hub'; import Tracking from '~/tracking'; import toast from '~/vue_shared/plugins/global_toast'; import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; -import NewHeaderActionsPopover from '~/issues/show/components/new_header_actions_popover.vue'; import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import IssuableLockForm from '~/sidebar/components/lock/issuable_lock_form.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -70,7 +65,6 @@ export default { GlLink, GlModal, AbuseCategorySelector, - NewHeaderActionsPopover, SidebarSubscriptionsWidget, IssuableLockForm, }, @@ -138,7 +132,7 @@ export default { issueTypeText() { const { issueType } = this; - return IssuableTypeText[issueType] ?? issueType; + return issuableTypeText[issueType] ?? issueType; }, buttonText() { return this.isClosed @@ -278,11 +272,6 @@ export default { edit() { issuesEventHub.$emit('open.form'); }, - dismissPopover() { - if (this.isMrSidebarMoved && !parseBoolean(getCookie(`${NEW_ACTIONS_POPOVER_KEY}`))) { - setCookie(NEW_ACTIONS_POPOVER_KEY, true); - } - }, copyReference() { toast(__('Reference copied')); }, @@ -390,17 +379,6 @@ export default { {{ $options.i18n.edit }} </gl-button> - <gl-button - v-if="showToggleIssueStateButton && !glFeatures.moveCloseIntoDropdown" - class="gl-display-none gl-sm-display-inline-flex!" - :data-qa-selector="qaSelector" - :loading="isToggleStateButtonLoading" - data-testid="toggle-issue-state-button" - @click="toggleIssueState" - > - {{ buttonText }} - </gl-button> - <gl-dropdown v-if="hasDesktopDropdown" id="new-actions-header-dropdown" @@ -415,9 +393,8 @@ export default { data-testid="desktop-dropdown" no-caret right - @shown="dismissPopover" > - <template v-if="showMovedSidebarOptions"> + <template v-if="showMovedSidebarOptions && !glFeatures.notificationsTodosButtons"> <sidebar-subscriptions-widget :iid="String(iid)" :full-path="fullPath" @@ -428,7 +405,7 @@ export default { <gl-dropdown-divider /> </template> <gl-dropdown-item - v-if="showToggleIssueStateButton && glFeatures.moveCloseIntoDropdown" + v-if="showToggleIssueStateButton" data-testid="toggle-issue-state-button" @click="toggleIssueState" > @@ -492,7 +469,6 @@ export default { </template> </gl-dropdown> - <new-header-actions-popover v-if="isMrSidebarMoved" :issue-type="issueType" /> <gl-modal ref="blockedByIssuesModal" modal-id="blocked-by-issues-modal" 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 1905678209f..7d2b371801b 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 @@ -178,7 +178,7 @@ export default { id="timeline-input-hours" v-model="hourPickerInput" data-testid="input-hours" - size="xs" + width="xs" type="number" min="00" max="23" @@ -189,7 +189,7 @@ export default { v-model="minutePickerInput" class="gl-ml-3" data-testid="input-minutes" - size="xs" + width="xs" type="number" min="00" max="59" diff --git a/app/assets/javascripts/issues/show/components/new_header_actions_popover.vue b/app/assets/javascripts/issues/show/components/new_header_actions_popover.vue deleted file mode 100644 index f7a324d9f3f..00000000000 --- a/app/assets/javascripts/issues/show/components/new_header_actions_popover.vue +++ /dev/null @@ -1,80 +0,0 @@ -<script> -import { GlPopover, GlButton } from '@gitlab/ui'; -import { s__, sprintf } from '~/locale'; -import { getCookie, parseBoolean, setCookie } from '~/lib/utils/common_utils'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { NEW_ACTIONS_POPOVER_KEY } from '~/issues/show/constants'; -import { IssuableTypeText } from '~/issues/constants'; - -export default { - name: 'NewHeaderActionsPopover', - i18n: { - popoverText: s__( - 'HeaderAction|Notifications and other %{issueType} actions have moved to this menu.', - ), - confirmButtonText: s__('HeaderAction|Okay!'), - }, - components: { - GlPopover, - GlButton, - }, - mixins: [glFeatureFlagMixin()], - props: { - issueType: { - type: String, - required: true, - }, - }, - data() { - return { - dismissKey: NEW_ACTIONS_POPOVER_KEY, - popoverDismissed: parseBoolean(getCookie(`${NEW_ACTIONS_POPOVER_KEY}`)), - }; - }, - computed: { - popoverText() { - return sprintf(this.$options.i18n.popoverText, { - issueType: IssuableTypeText[this.issueType], - }); - }, - showPopover() { - return !this.popoverDismissed && this.isMrSidebarMoved; - }, - isMrSidebarMoved() { - return this.glFeatures.movedMrSidebar; - }, - }, - methods: { - dismissPopover() { - this.popoverDismissed = true; - setCookie(this.dismissKey, this.popoverDismissed); - }, - }, -}; -</script> - -<template> - <gl-popover - v-if="showPopover" - target="new-actions-header-dropdown" - container="viewport" - placement="left" - :show="showPopover" - triggers="manual" - content="text" - :css-classes="['gl-p-2 new-header-popover']" - > - <template #title> - <div class="gl-font-base gl-font-weight-normal"> - {{ popoverText }} - </div> - </template> - <gl-button - data-testid="confirm-button" - variant="confirm" - type="submit" - @click="dismissPopover" - >{{ $options.i18n.confirmButtonText }}</gl-button - > - </gl-popover> -</template> diff --git a/app/assets/javascripts/issues/show/components/sticky_header.vue b/app/assets/javascripts/issues/show/components/sticky_header.vue index bcf10ee92bb..738bb2c2aa0 100644 --- a/app/assets/javascripts/issues/show/components/sticky_header.vue +++ b/app/assets/javascripts/issues/show/components/sticky_header.vue @@ -1,12 +1,13 @@ <script> -import { GlBadge, GlIcon, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui'; +import { GlBadge, GlIcon, GlIntersectionObserver, GlLink } from '@gitlab/ui'; +import HiddenBadge from '~/issuable/components/hidden_badge.vue'; +import LockedBadge from '~/issuable/components/locked_badge.vue'; import { issuableStatusText, STATUS_CLOSED, TYPE_EPIC, WORKSPACE_PROJECT, } from '~/issues/constants'; -import SafeHtml from '~/vue_shared/directives/safe_html'; import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; export default { @@ -16,10 +17,9 @@ export default { GlBadge, GlIcon, GlIntersectionObserver, - }, - directives: { - GlTooltip: GlTooltipDirective, - SafeHtml, + GlLink, + HiddenBadge, + LockedBadge, }, props: { isConfidential: { @@ -54,10 +54,6 @@ export default { type: String, required: true, }, - titleHtml: { - type: String, - required: true, - }, }, computed: { isClosed() { @@ -94,35 +90,20 @@ export default { <gl-icon :name="statusIcon" /> <span class="gl-display-none gl-sm-display-block gl-ml-2">{{ statusText }}</span> </gl-badge> - <span - v-if="isLocked" - v-gl-tooltip.bottom - data-testid="locked" - class="issuable-warning-icon" - :title="__('This issue is locked. Only project members can comment.')" - > - <gl-icon name="lock" :aria-label="__('Locked')" /> - </span> <confidentiality-badge v-if="isConfidential" :issuable-type="issuableType" :workspace-type="$options.WORKSPACE_PROJECT" /> - <span - v-if="isHidden" - v-gl-tooltip.bottom - :title="__('This issue is hidden because its author has been banned')" - data-testid="hidden" - class="issuable-warning-icon" - > - <gl-icon name="spam" /> - </span> - <a - v-safe-html="titleHtml || title" + <locked-badge v-if="isLocked" :issuable-type="issuableType" /> + <hidden-badge v-if="isHidden" :issuable-type="issuableType" /> + <gl-link + class="gl-font-weight-bold gl-text-black-normal gl-text-truncate" href="#top" - class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0 gl-text-black-normal" + :title="title" > - </a> + {{ title }} + </gl-link> </div> </div> </transition> diff --git a/app/assets/javascripts/issues/show/constants.js b/app/assets/javascripts/issues/show/constants.js index 6320e4ef266..4d8c11f9669 100644 --- a/app/assets/javascripts/issues/show/constants.js +++ b/app/assets/javascripts/issues/show/constants.js @@ -17,5 +17,3 @@ export const issueState = { issueType: undefined, isDirty: false, }; - -export const NEW_ACTIONS_POPOVER_KEY = 'new-actions-popover-viewed'; diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js index b94f88f690e..cd5c6f4825a 100644 --- a/app/assets/javascripts/issues/show/index.js +++ b/app/assets/javascripts/issues/show/index.js @@ -131,8 +131,8 @@ export function initIssuableApp(store) { isLocked: this.getNoteableData?.discussion_locked, issuableStatus: this.getNoteableData?.state, issuableType: issueType, - issueId: this.getNoteableData?.id, - issueIid: this.getNoteableData?.iid, + issueId: this.getNoteableData?.id.toString(), + issueIid: this.getNoteableData?.iid.toString(), showTitleBorder: issueType !== TYPE_INCIDENT, }, }); diff --git a/app/assets/javascripts/jira_connect/branches/pages/index.vue b/app/assets/javascripts/jira_connect/branches/pages/index.vue index 3824e2350e8..3b92ace694c 100644 --- a/app/assets/javascripts/jira_connect/branches/pages/index.vue +++ b/app/assets/javascripts/jira_connect/branches/pages/index.vue @@ -56,6 +56,7 @@ export default { :title="$options.i18n.I18N_NEW_BRANCH_SUCCESS_TITLE" :description="$options.i18n.I18N_NEW_BRANCH_SUCCESS_MESSAGE" :svg-path="successStateSvgPath" + :svg-height="null" /> </div> </template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js index 72fd25a6230..1a10360ed30 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/constants.js +++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js @@ -37,11 +37,11 @@ export const I18N_OAUTH_FAILED_MESSAGE = s__( export const INTEGRATIONS_DOC_LINK = helpPagePath('integration/jira/development_panel', { anchor: 'use-the-integration', }); -export const OAUTH_SELF_MANAGED_DOC_LINK = helpPagePath('integration/jira/connect-app', { - anchor: 'connect-the-gitlab-for-jira-cloud-app-for-self-managed-instances', +export const OAUTH_SELF_MANAGED_DOC_LINK = helpPagePath('administration/settings/jira_cloud_app', { + anchor: 'set-up-oauth-authentication', }); -export const FAILED_TO_UPDATE_DOC_LINK = helpPagePath('integration/jira/connect-app', { - anchor: 'failed-to-update-the-gitlab-instance-for-self-managed-instances', +export const FAILED_TO_UPDATE_DOC_LINK = helpPagePath('administration/settings/jira_cloud_app', { + anchor: 'failed-to-update-the-gitlab-instance', }); export const GITLAB_COM_BASE_PATH = 'https://gitlab.com'; diff --git a/app/assets/javascripts/jira_import/components/jira_import_progress.vue b/app/assets/javascripts/jira_import/components/jira_import_progress.vue index 78f10decd31..2a9ce9b15ef 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_progress.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_progress.vue @@ -56,6 +56,7 @@ export default { <template> <gl-empty-state :svg-path="illustration" + :svg-height="null" :title="__('Import in progress')" :primary-button-text="__('View issues')" :primary-button-link="issuesLink" diff --git a/app/assets/javascripts/jira_import/components/jira_import_setup.vue b/app/assets/javascripts/jira_import/components/jira_import_setup.vue index 285c5c815ac..58154256357 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_setup.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_setup.vue @@ -22,6 +22,7 @@ export default { <template> <gl-empty-state :svg-path="illustration" + :svg-height="null" title="" :description="__('You will first need to set up Jira Integration to use this feature.')" :primary-button-text="__('Set up Jira Integration')" diff --git a/app/assets/javascripts/labels/index.js b/app/assets/javascripts/labels/index.js index bb3975ce61d..79f125be5b7 100644 --- a/app/assets/javascripts/labels/index.js +++ b/app/assets/javascripts/labels/index.js @@ -120,10 +120,10 @@ export function initAdminLabels() { const emptyState = document.querySelector('.js-admin-labels-empty-state'); function removeLabelSuccessCallback() { - this.closest('li.label-list-item').classList.add('gl-display-none!'); + this.closest('.js-label-list-item').classList.add('gl-display-none!'); const labelsCount = document.querySelectorAll( - 'ul.manage-labels-list li.label-list-item:not(.gl-display-none\\!)', + 'ul.manage-labels-list .js-label-list-item:not(.gl-display-none\\!)', ).length; // update labels count in UI diff --git a/app/assets/javascripts/labels/label_manager.js b/app/assets/javascripts/labels/label_manager.js index e3d56df53f8..e684e7f1649 100644 --- a/app/assets/javascripts/labels/label_manager.js +++ b/app/assets/javascripts/labels/label_manager.js @@ -68,7 +68,7 @@ export default class LabelManager { const $detachedLabel = $label.detach(); this.toggleLabelPriorityBadge($detachedLabel, action); - const $labelEls = $target.find('li.label-list-item'); + const $labelEls = $target.find('.js-label-list-item'); /* * If there is a label element in the target, we'd want to diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js index 36f387205f8..4354785e585 100644 --- a/app/assets/javascripts/lazy_loader.js +++ b/app/assets/javascripts/lazy_loader.js @@ -170,7 +170,7 @@ export default class LazyLoader { img.classList.remove('lazy'); img.classList.add('js-lazy-loaded'); // eslint-disable-next-line no-param-reassign - img.dataset.qa_selector = 'js_lazy_loaded_content'; + img.dataset.testid = 'js-lazy-loaded-content'; } } } diff --git a/app/assets/javascripts/lib/utils/global_alerts.js b/app/assets/javascripts/lib/utils/global_alerts.js new file mode 100644 index 00000000000..c1e4204189e --- /dev/null +++ b/app/assets/javascripts/lib/utils/global_alerts.js @@ -0,0 +1,37 @@ +export const GLOBAL_ALERTS_SESSION_STORAGE_KEY = 'vueGlobalAlerts'; + +/** + * Get global alerts from session storage + */ +export const getGlobalAlerts = () => { + return JSON.parse(sessionStorage.getItem(GLOBAL_ALERTS_SESSION_STORAGE_KEY) || '[]'); +}; + +/** + * Set alerts in session storage + * @param {{id: String, title?: String, message: String, variant: String, dismissible?: Boolean, persistOnPages?: String[]}[]} alerts + */ +export const setGlobalAlerts = (alerts) => { + sessionStorage.setItem( + GLOBAL_ALERTS_SESSION_STORAGE_KEY, + JSON.stringify([ + ...alerts.map(({ dismissible = true, persistOnPages = [], ...alert }) => ({ + dismissible, + persistOnPages, + ...alert, + })), + ]), + ); +}; + +/** + * Remove global alert by id + * @param {String} id + */ +export const removeGlobalAlertById = (id) => { + const existingAlerts = getGlobalAlerts(); + sessionStorage.setItem( + GLOBAL_ALERTS_SESSION_STORAGE_KEY, + JSON.stringify(existingAlerts.filter((alert) => alert.id !== id)), + ); +}; diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index ea0520e3157..a579b010877 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -1,3 +1,5 @@ +import { getGlobalAlerts, setGlobalAlerts } from './global_alerts'; + export const DASH_SCOPE = '-'; export const PATH_SEPARATOR = '/'; @@ -241,7 +243,11 @@ export function removeParams(params, url = window.location.href, skipEncoding = return `${root}${writableQuery}${writableFragment}`; } -export const getLocationHash = (hash = window.location.hash) => hash.split('#')[1]; +/** + * Returns value after the '#' in the location hash + * @returns Current value of the hash, undefined if not set + */ +export const getLocationHash = () => window.location.hash?.split('#')[1]; /** * Returns a boolean indicating whether the URL hash contains the given string value @@ -717,6 +723,20 @@ export function visitUrl(destination, external = false) { } } +/** + * Navigates to a URL and display alerts. + * + * If destination is a querystring, it will be automatically transformed into a fully qualified URL. + * If the URL is not a safe URL (see isSafeURL implementation), this function will log an exception into Sentry. + * + * @param {*} destination - url to navigate to. This can be a fully qualified URL or a querystring. + * @param {{id: String, title?: String, message: String, variant: String, dismissible?: Boolean, persistOnPages?: String[]}[]} alerts - Alerts to display + */ +export function visitUrlWithAlerts(destination, alerts) { + setGlobalAlerts([...getGlobalAlerts(), ...alerts]); + visitUrl(destination); +} + export function refreshCurrentPage() { visitUrl(window.location.href); } diff --git a/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue b/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue index 920febb0e67..68bfb99a139 100644 --- a/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue +++ b/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue @@ -77,7 +77,7 @@ export default { <template> <gl-disclosure-dropdown-item - data-qa-selector="delete_member_dropdown_item" + data-testid="delete-member-dropdown-item" @action="showRemoveMemberModal(modalData)" > <template #list-item> diff --git a/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue b/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue index 25dc4831b11..a8c97060915 100644 --- a/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue +++ b/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue @@ -109,7 +109,6 @@ export default { no-caret placement="right" data-testid="user-action-dropdown" - data-qa-selector="user_action_dropdown" > <disable-two-factor-dropdown-item v-if="permissions.canDisableTwoFactor" diff --git a/app/assets/javascripts/members/components/app.vue b/app/assets/javascripts/members/components/app.vue index a70ee8fc865..06499b6d2c6 100644 --- a/app/assets/javascripts/members/components/app.vue +++ b/app/assets/javascripts/members/components/app.vue @@ -12,10 +12,7 @@ export default { components: { MembersTable, FilterSortContainer, GlAlert }, provide() { return { - // We can't use this.namespace due to bug in vue-apollo when - // provide is called in beforeCreate - // See https://github.com/vuejs/vue-apollo/pull/1153 for details - namespace: this.$options.propsData.namespace, + namespace: this.namespace, }; }, props: { 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 0e5e394dd40..94773535e85 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 @@ -20,8 +20,7 @@ export default { name: 'MembersFilteredSearchBar', components: { FilteredSearchBar }, availableTokens: AVAILABLE_FILTERED_SEARCH_TOKENS, - searchButtonAttributes: { 'data-qa-selector': 'search_button' }, - searchInputAttributes: { 'data-qa-selector': 'search_bar_input' }, + searchButtonAttributes: { 'data-testid': 'search-button' }, inject: { namespace: {}, sourceId: {}, diff --git a/app/assets/javascripts/members/components/members_tabs.vue b/app/assets/javascripts/members/components/members_tabs.vue index 75241d1ff26..449ad20e7ab 100644 --- a/app/assets/javascripts/members/components/members_tabs.vue +++ b/app/assets/javascripts/members/components/members_tabs.vue @@ -22,7 +22,7 @@ export const TABS = [ { namespace: MEMBER_TYPES.group, title: __('Groups'), - attrs: { 'data-qa-selector': 'groups_list_tab' }, + attrs: { 'data-testid': 'groups-list-tab' }, queryParamValue: TAB_QUERY_PARAM_VALUES.group, }, { @@ -112,6 +112,7 @@ export default { <template> <gl-tabs v-model="selectedTabIndex" + content-class="gl-py-0" sync-active-tab-with-query-params :query-param-name="$options.ACTIVE_TAB_QUERY_PARAM_NAME" > diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 1bc67522e82..2095f24eb84 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -93,7 +93,7 @@ function mountPipelines() { const { mrWidgetData } = gl; const table = new Vue({ components: { - CommitPipelinesTable: () => { + MergeRequestPipelinesTable: () => { return gon.features.mrPipelinesGraphql ? import('~/ci/merge_requests/components/pipelines_table_wrapper.vue') : import('~/commit/pipelines/legacy_pipelines_table_wrapper.vue'); @@ -109,10 +109,10 @@ function mountPipelines() { manualActionsLimit: 50, mergeRequestId: mrWidgetData ? mrWidgetData.iid : null, sourceProjectFullPath: mrWidgetData?.source_project_full_path || '', - withFailedJobsDetails: true, + useFailedJobsWidget: gon.features?.ciJobFailuresInMr || false, }, render(createElement) { - return createElement('commit-pipelines-table', { + return createElement('merge-request-pipelines-table', { props: { endpoint: pipelineTableViewEl.dataset.endpoint, emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath, @@ -347,11 +347,11 @@ export default class MergeRequestTabs { } // this.hideSidebar(); this.resetViewContainer(); - this.commitPipelinesTable = destroyPipelines(this.commitPipelinesTable); + this.mergeRequestPipelinesTable = destroyPipelines(this.mergeRequestPipelinesTable); } else if (action === 'new') { this.expandView(); this.resetViewContainer(); - this.commitPipelinesTable = destroyPipelines(this.commitPipelinesTable); + this.mergeRequestPipelinesTable = destroyPipelines(this.mergeRequestPipelinesTable); } else if (this.isDiffAction(action)) { if (!isInVueNoteablePage()) { /* @@ -366,7 +366,7 @@ export default class MergeRequestTabs { } // this.hideSidebar(); this.expandViewContainer(); - this.commitPipelinesTable = destroyPipelines(this.commitPipelinesTable); + this.mergeRequestPipelinesTable = destroyPipelines(this.mergeRequestPipelinesTable); this.commitsTab.classList.remove('active'); } else if (action === 'pipelines') { // this.hideSidebar(); @@ -384,7 +384,7 @@ export default class MergeRequestTabs { // this.showSidebar(); this.resetViewContainer(); - this.commitPipelinesTable = destroyPipelines(this.commitPipelinesTable); + this.mergeRequestPipelinesTable = destroyPipelines(this.mergeRequestPipelinesTable); } renderGFM(document.querySelector('.detail-page-description')); @@ -522,7 +522,7 @@ export default class MergeRequestTabs { } mountPipelinesView() { - this.commitPipelinesTable = mountPipelines(); + this.mergeRequestPipelinesTable = mountPipelines(); } // load the diff tab content from the backend diff --git a/app/assets/javascripts/merge_requests/components/compare_app.vue b/app/assets/javascripts/merge_requests/components/compare_app.vue index c7c16e91e4c..538aa090aa8 100644 --- a/app/assets/javascripts/merge_requests/components/compare_app.vue +++ b/app/assets/javascripts/merge_requests/components/compare_app.vue @@ -32,6 +32,9 @@ export default { toggleClass: { default: () => ({}), }, + compareSide: { + default: null, + }, }, props: { currentBranch: { @@ -116,6 +119,7 @@ export default { :input-name="inputs.branch.name" :default="currentBranch" :toggle-class="toggleClass.branch" + :data-qa-compare-side="compareSide" data-testid="compare-dropdown" @selected="selectBranch" /> diff --git a/app/assets/javascripts/merge_requests/components/compare_dropdown.vue b/app/assets/javascripts/merge_requests/components/compare_dropdown.vue index 2855d704507..20989206a51 100644 --- a/app/assets/javascripts/merge_requests/components/compare_dropdown.vue +++ b/app/assets/javascripts/merge_requests/components/compare_dropdown.vue @@ -137,7 +137,6 @@ export default { 'gl-align-items-flex-start! gl-justify-content-start! mr-compare-dropdown', toggleClass, ]" - data-testid="source-branch-dropdown" @shown="fetchData" @search="searchData" @select="selectItem" diff --git a/app/assets/javascripts/merge_requests/components/header_metadata.vue b/app/assets/javascripts/merge_requests/components/header_metadata.vue deleted file mode 100644 index fce7ba385b4..00000000000 --- a/app/assets/javascripts/merge_requests/components/header_metadata.vue +++ /dev/null @@ -1,69 +0,0 @@ -<script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -// eslint-disable-next-line no-restricted-imports -import { mapGetters } from 'vuex'; -import { __ } from '~/locale'; -import { TYPE_ISSUE, WORKSPACE_PROJECT } from '~/issues/constants'; -import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; - -export default { - TYPE_ISSUE, - WORKSPACE_PROJECT, - components: { - GlIcon, - ConfidentialityBadge, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - inject: ['hidden'], - computed: { - ...mapGetters(['getNoteableData']), - isLocked() { - return this.getNoteableData.discussion_locked; - }, - isConfidential() { - return this.getNoteableData.confidential; - }, - warningIconsMeta() { - return [ - { - iconName: 'lock', - visible: this.isLocked, - dataTestId: 'locked', - tooltip: __('This merge request is locked. Only project members can comment.'), - }, - { - iconName: 'spam', - visible: this.hidden, - dataTestId: 'hidden', - tooltip: __('This merge request is hidden because its author has been banned'), - }, - ]; - }, - }, -}; -</script> - -<template> - <div class="gl-display-inline-block"> - <confidentiality-badge - v-if="isConfidential" - class="gl-mr-3" - :issuable-type="$options.TYPE_ISSUE" - :workspace-type="$options.WORKSPACE_PROJECT" - /> - <template v-for="meta in warningIconsMeta"> - <div - v-if="meta.visible" - :key="meta.iconName" - v-gl-tooltip.bottom - :data-testid="meta.dataTestId" - :title="meta.tooltip || null" - class="issuable-warning-icon gl-mr-3 gl-mt-2 gl-display-flex gl-justify-content-center gl-align-items-center" - > - <gl-icon :name="meta.iconName" class="icon" /> - </div> - </template> - </div> -</template> diff --git a/app/assets/javascripts/merge_requests/components/merge_request_header.vue b/app/assets/javascripts/merge_requests/components/merge_request_header.vue new file mode 100644 index 00000000000..b2e7245bd88 --- /dev/null +++ b/app/assets/javascripts/merge_requests/components/merge_request_header.vue @@ -0,0 +1,113 @@ +<script> +import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports +import { mapGetters } from 'vuex'; +import HiddenBadge from '~/issuable/components/hidden_badge.vue'; +import LockedBadge from '~/issuable/components/locked_badge.vue'; +import StatusBadge from '~/issuable/components/status_badge.vue'; +import { TYPE_ISSUE, TYPE_MERGE_REQUEST, WORKSPACE_PROJECT } from '~/issues/constants'; +import { fetchPolicies } from '~/lib/graphql'; +import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; + +export const badgeState = Vue.observable({ + state: '', + updateStatus: null, +}); + +export default { + TYPE_ISSUE, + TYPE_MERGE_REQUEST, + WORKSPACE_PROJECT, + components: { + ConfidentialityBadge, + LockedBadge, + HiddenBadge, + StatusBadge, + }, + inject: { + query: { default: null }, + projectPath: { default: null }, + hidden: { default: false }, + iid: { default: null }, + }, + props: { + initialState: { + type: String, + required: false, + default: null, + }, + }, + data() { + if (!this.iid) { + return { + state: this.initialState, + }; + } + + if (!badgeState.state && this.initialState) { + badgeState.state = this.initialState; + } + + return badgeState; + }, + computed: { + ...mapGetters(['getNoteableData']), + isLocked() { + return this.getNoteableData.discussion_locked; + }, + isConfidential() { + return this.getNoteableData.confidential; + }, + }, + created() { + if (!badgeState.updateStatus) { + badgeState.updateStatus = this.fetchState; + } + }, + beforeDestroy() { + if (badgeState.updateStatus && this.query) { + badgeState.updateStatus = null; + } + }, + methods: { + async fetchState() { + const { data } = await this.$apollo.query({ + query: this.query, + variables: { + projectPath: this.projectPath, + iid: this.iid, + }, + fetchPolicy: fetchPolicies.NO_CACHE, + }); + + badgeState.state = data?.workspace?.issuable?.state; + }, + }, +}; +</script> + +<template> + <span class="gl-display-contents"> + <status-badge + class="gl-align-self-center gl-mr-2" + :issuable-type="$options.TYPE_MERGE_REQUEST" + :state="state" + /> + <confidentiality-badge + v-if="isConfidential" + class="gl-align-self-center gl-mr-2" + :issuable-type="$options.TYPE_ISSUE" + :workspace-type="$options.WORKSPACE_PROJECT" + /> + <locked-badge + v-if="isLocked" + class="gl-align-self-center gl-mr-2" + :issuable-type="$options.TYPE_MERGE_REQUEST" + /> + <hidden-badge + v-if="hidden" + class="gl-align-self-center gl-mr-2" + :issuable-type="$options.TYPE_MERGE_REQUEST" + /> + </span> +</template> diff --git a/app/assets/javascripts/merge_requests/components/merge_request_status_badge.vue b/app/assets/javascripts/merge_requests/components/merge_request_status_badge.vue deleted file mode 100644 index 3d5478757a8..00000000000 --- a/app/assets/javascripts/merge_requests/components/merge_request_status_badge.vue +++ /dev/null @@ -1,74 +0,0 @@ -<script> -import Vue from 'vue'; -import { fetchPolicies } from '~/lib/graphql'; -import StatusBadge from '~/issuable/components/status_badge.vue'; - -export const badgeState = Vue.observable({ - state: '', - updateStatus: null, -}); - -export default { - components: { - StatusBadge, - }, - inject: { - query: { default: null }, - projectPath: { default: null }, - iid: { default: null }, - }, - props: { - initialState: { - type: String, - required: false, - default: null, - }, - issuableType: { - type: String, - required: false, - default: '', - }, - }, - data() { - if (!this.iid) { - return { - state: this.initialState, - }; - } - - if (!badgeState.state && this.initialState) { - badgeState.state = this.initialState; - } - - return badgeState; - }, - created() { - if (!badgeState.updateStatus) { - badgeState.updateStatus = this.fetchState; - } - }, - beforeDestroy() { - if (badgeState.updateStatus && this.query) { - badgeState.updateStatus = null; - } - }, - methods: { - async fetchState() { - const { data } = await this.$apollo.query({ - query: this.query, - variables: { - projectPath: this.projectPath, - iid: this.iid, - }, - fetchPolicy: fetchPolicies.NO_CACHE, - }); - - badgeState.state = data?.workspace?.issuable?.state; - }, - }, -}; -</script> - -<template> - <status-badge class="gl-align-self-center gl-mr-3" :issuable-type="issuableType" :state="state" /> -</template> diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue index c1e88a901c4..e8bdb854334 100644 --- a/app/assets/javascripts/merge_requests/components/sticky_header.vue +++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue @@ -11,8 +11,10 @@ import StatusBadge from '~/issuable/components/status_badge.vue'; import { TYPE_MERGE_REQUEST } from '~/issues/constants'; import DiscussionCounter from '~/notes/components/discussion_counter.vue'; import TodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; +import SubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import titleSubscription from '../queries/title.subscription.graphql'; +import { badgeState } from './merge_request_header.vue'; export default { TYPE_MERGE_REQUEST, @@ -46,6 +48,7 @@ export default { DiscussionCounter, StatusBadge, TodoWidget, + SubscriptionsWidget, ClipboardButton, }, directives: { @@ -71,6 +74,9 @@ export default { activeTab: (state) => state.page.activeTab, doneFetchingBatchDiscussions: (state) => state.notes.doneFetchingBatchDiscussions, }), + badgeState() { + return badgeState; + }, issuableId() { return convertToGraphQLId(TYPENAME_MERGE_REQUEST, this.getNoteableData.id); }, @@ -80,6 +86,9 @@ export default { isSignedIn() { return isLoggedIn(); }, + isNotificationsTodosButtons() { + return this.glFeatures.notificationsTodosButtons && this.glFeatures.movedMrSidebar; + }, }, watch: { discussionTabCounter(val) { @@ -120,7 +129,7 @@ export default { <status-badge class="gl-align-self-center gl-mr-3" :issuable-type="$options.TYPE_MERGE_REQUEST" - :state="getNoteableData.state" + :state="badgeState.state" /> <a v-safe-html:[$options.safeHtmlConfig]="titleHtml" @@ -189,13 +198,23 @@ export default { </ul> <div class="gl-display-none gl-lg-display-flex gl-align-items-center gl-ml-auto"> <discussion-counter blocks-merge hide-options /> - <todo-widget + <div v-if="isSignedIn" - :issuable-id="issuableId" - :issuable-iid="issuableIid" - :full-path="projectPath" - issuable-type="merge_request" - /> + :class="{ 'gl-display-flex gl-gap-3': isNotificationsTodosButtons }" + > + <todo-widget + :issuable-id="issuableId" + :issuable-iid="issuableIid" + :full-path="projectPath" + issuable-type="merge_request" + /> + <subscriptions-widget + v-if="isNotificationsTodosButtons" + :iid="issuableIid" + :full-path="projectPath" + issuable-type="merge_request" + /> + </div> </div> </div> </div> diff --git a/app/assets/javascripts/merge_requests/index.js b/app/assets/javascripts/merge_requests/index.js deleted file mode 100644 index 29218eb53e0..00000000000 --- a/app/assets/javascripts/merge_requests/index.js +++ /dev/null @@ -1,19 +0,0 @@ -import Vue from 'vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import HeaderMetadata from './components/header_metadata.vue'; - -export function mountHeaderMetadata(store) { - const el = document.querySelector('.js-header-metadata-root'); - - if (!el) { - return null; - } - - return new Vue({ - el, - name: 'HeaderMetadataRoot', - store, - provide: { hidden: parseBoolean(el.dataset.hidden) }, - render: (createElement) => createElement(HeaderMetadata), - }); -} diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue index 747e92b9e85..8c7460940a0 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue +++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue @@ -6,18 +6,12 @@ export default { type: String, required: true, }, - sectionLabel: { - type: String, - required: false, - default: '', - }, }, }; </script> <template> <tr> - <td class="gl-text-secondary gl-font-weight-bold">{{ sectionLabel }}</td> <td class="gl-font-weight-bold">{{ label }}</td> <td> <slot></slot> diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue index a68fb7d340a..43d28e3d699 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue +++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue @@ -1,7 +1,9 @@ <script> -import { GlAvatarLabeled, GlLink } from '@gitlab/ui'; +import { GlAvatarLabeled, GlLink, GlTableLite } from '@gitlab/ui'; +import { isEmpty, maxBy, range } from 'lodash'; import ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue'; import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue'; +import { __, sprintf } from '~/locale'; import DetailRow from './components/candidate_detail_row.vue'; import { @@ -22,6 +24,11 @@ import { JOB_LABEL, CI_USER_LABEL, CI_MR_LABEL, + PERFORMANCE_LABEL, + NO_PARAMETERS_MESSAGE, + NO_METRICS_MESSAGE, + NO_METADATA_MESSAGE, + NO_CI_MESSAGE, } from './translations'; export default { @@ -32,6 +39,7 @@ export default { DetailRow, GlAvatarLabeled, GlLink, + GlTableLite, }, props: { candidate: { @@ -54,6 +62,14 @@ export default { JOB_LABEL, CI_USER_LABEL, CI_MR_LABEL, + PARAMETERS_LABEL, + METRICS_LABEL, + METADATA_LABEL, + PERFORMANCE_LABEL, + NO_PARAMETERS_MESSAGE, + NO_METRICS_MESSAGE, + NO_METADATA_MESSAGE, + NO_CI_MESSAGE, }, computed: { info() { @@ -62,21 +78,38 @@ export default { ciJob() { return Object.freeze(this.info.ci_job); }, - sections() { - return [ - { - sectionName: PARAMETERS_LABEL, - sectionValues: this.candidate.params, - }, - { - sectionName: METRICS_LABEL, - sectionValues: this.candidate.metrics, - }, - { - sectionName: METADATA_LABEL, - sectionValues: this.candidate.metadata, - }, - ]; + hasMetadata() { + return !isEmpty(this.candidate.metadata); + }, + hasParameters() { + return !isEmpty(this.candidate.params); + }, + hasMetrics() { + return !isEmpty(this.candidate.metrics); + }, + metricsTableFields() { + const maxStep = maxBy(this.candidate.metrics, 'step').step; + const rowClass = 'gl-p-3!'; + + const cssClasses = { thClass: rowClass, tdClass: rowClass }; + + const fields = range(maxStep + 1).map((step) => ({ + key: step.toString(), + label: sprintf(__('Step %{step}'), { step }), + ...cssClasses, + })); + + return [{ key: 'name', label: __('Metric'), ...cssClasses }, ...fields]; + }, + metricsTableItems() { + const items = {}; + this.candidate.metrics.forEach((metric) => { + const metricRow = items[metric.name] || { name: metric.name }; + metricRow[metric.step] = metric.value; + items[metric.name] = metricRow; + }); + + return Object.values(items); }, }, }; @@ -93,33 +126,37 @@ export default { /> </model-experiments-header> - <table class="candidate-details gl-w-full"> - <tbody> - <tr class="divider"></tr> - - <detail-row :label="$options.i18n.ID_LABEL" :section-label="$options.i18n.INFO_LABEL"> - {{ info.iid }} - </detail-row> + <section class="gl-mb-6"> + <table class="candidate-details"> + <tbody> + <detail-row :label="$options.i18n.ID_LABEL"> + {{ info.iid }} + </detail-row> - <detail-row :label="$options.i18n.MLFLOW_ID_LABEL">{{ info.eid }}</detail-row> + <detail-row :label="$options.i18n.MLFLOW_ID_LABEL">{{ info.eid }}</detail-row> - <detail-row :label="$options.i18n.STATUS_LABEL">{{ info.status }}</detail-row> + <detail-row :label="$options.i18n.STATUS_LABEL">{{ info.status }}</detail-row> - <detail-row :label="$options.i18n.EXPERIMENT_LABEL"> - <gl-link :href="info.path_to_experiment"> - {{ info.experiment_name }} - </gl-link> - </detail-row> + <detail-row :label="$options.i18n.EXPERIMENT_LABEL"> + <gl-link :href="info.path_to_experiment"> + {{ info.experiment_name }} + </gl-link> + </detail-row> - <detail-row v-if="info.path_to_artifact" :label="$options.i18n.ARTIFACTS_LABEL"> - <gl-link :href="info.path_to_artifact"> - {{ $options.i18n.ARTIFACTS_LABEL }} - </gl-link> - </detail-row> + <detail-row v-if="info.path_to_artifact" :label="$options.i18n.ARTIFACTS_LABEL"> + <gl-link :href="info.path_to_artifact"> + {{ $options.i18n.ARTIFACTS_LABEL }} + </gl-link> + </detail-row> + </tbody> + </table> + </section> - <template v-if="ciJob"> - <tr class="divider"></tr> + <section class="gl-mb-6"> + <h4>{{ $options.i18n.CI_SECTION_LABEL }}</h4> + <table v-if="ciJob" class="candidate-details"> + <tbody> <detail-row :label="$options.i18n.JOB_LABEL" :section-label="$options.i18n.CI_SECTION_LABEL" @@ -142,21 +179,53 @@ export default { !{{ ciJob.merge_request.iid }} {{ ciJob.merge_request.title }} </gl-link> </detail-row> - </template> + </tbody> + </table> - <template v-for="{ sectionName, sectionValues } in sections"> - <tr v-if="sectionValues" :key="sectionName" class="divider"></tr> + <div v-else class="gl-text-secondary">{{ $options.i18n.NO_CI_MESSAGE }}</div> + </section> - <detail-row - v-for="(item, index) in sectionValues" - :key="item.name" - :label="item.name" - :section-label="index === 0 ? sectionName : ''" - > + <section class="gl-mb-6"> + <h4>{{ $options.i18n.PARAMETERS_LABEL }}</h4> + + <table v-if="hasParameters" class="candidate-details"> + <tbody> + <detail-row v-for="item in candidate.params" :key="item.name" :label="item.name"> {{ item.value }} </detail-row> - </template> - </tbody> - </table> + </tbody> + </table> + + <div v-else class="gl-text-secondary">{{ $options.i18n.NO_PARAMETERS_MESSAGE }}</div> + </section> + + <section class="gl-mb-6"> + <h4>{{ $options.i18n.METADATA_LABEL }}</h4> + + <table v-if="hasMetadata" class="candidate-details"> + <tbody> + <detail-row v-for="item in candidate.metadata" :key="item.name" :label="item.name"> + {{ item.value }} + </detail-row> + </tbody> + </table> + + <div v-else class="gl-text-secondary">{{ $options.i18n.NO_METADATA_MESSAGE }}</div> + </section> + + <section class="gl-mb-6"> + <h4>{{ $options.i18n.PERFORMANCE_LABEL }}</h4> + + <div v-if="hasMetrics" class="gl-overflow-x-auto"> + <gl-table-lite + :items="metricsTableItems" + :fields="metricsTableFields" + class="gl-w-auto" + hover + /> + </div> + + <div v-else class="gl-text-secondary">{{ $options.i18n.NO_METRICS_MESSAGE }}</div> + </section> </div> </template> diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js index fa9518f3e27..98988e1db35 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js +++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js @@ -9,13 +9,18 @@ export const EXPERIMENT_LABEL = s__('MlExperimentTracking|Experiment'); export const ARTIFACTS_LABEL = s__('MlExperimentTracking|Artifacts'); export const PARAMETERS_LABEL = s__('MlExperimentTracking|Parameters'); export const METRICS_LABEL = s__('MlExperimentTracking|Metrics'); +export const PERFORMANCE_LABEL = s__('MlExperimentTracking|Model performance'); export const METADATA_LABEL = s__('MlExperimentTracking|Metadata'); +export const NO_PARAMETERS_MESSAGE = s__('MlExperimentTracking|No logged parameters'); +export const NO_METRICS_MESSAGE = s__('MlExperimentTracking|No logged metrics'); +export const NO_METADATA_MESSAGE = s__('MlExperimentTracking|No logged metadata'); +export const NO_CI_MESSAGE = s__('MlExperimentTracking|Candidate not linked to a CI build'); export const DELETE_CANDIDATE_CONFIRMATION_MESSAGE = s__( 'MlExperimentTracking|Deleting this candidate will delete the associated parameters, metrics, and metadata.', ); export const DELETE_CANDIDATE_PRIMARY_ACTION_LABEL = s__('MlExperimentTracking|Delete candidate'); export const DELETE_CANDIDATE_MODAL_TITLE = s__('MLExperimentTracking|Delete candidate?'); -export const CI_SECTION_LABEL = __('CI'); +export const CI_SECTION_LABEL = s__('MLExperimentTracking|CI Info'); export const JOB_LABEL = __('Job'); export const CI_USER_LABEL = s__('MlExperimentTracking|Triggered by'); export const CI_MR_LABEL = __('Merge request'); diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue index b543169d501..4710735f76e 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue @@ -72,6 +72,7 @@ export default { :primary-button-text="$options.i18n.CREATE_NEW_LABEL" :primary-button-link="$options.constants.CREATE_EXPERIMENT_HELP_PATH" :svg-path="emptyStateSvgPath" + :svg-height="null" :description="$options.i18n.EMPTY_STATE_DESCRIPTION_LABEL" class="gl-py-8" /> diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue index 25c06aa2f7f..28a27059b17 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue @@ -245,6 +245,7 @@ export default { :primary-button-text="$options.i18n.CREATE_NEW_LABEL" :primary-button-link="$options.constants.CREATE_CANDIDATE_HELP_PATH" :svg-path="emptyStateSvgPath" + :svg-height="null" :description="$options.i18n.EMPTY_STATE_DESCRIPTION_LABEL" class="gl-py-8" /> diff --git a/app/assets/javascripts/ml/model_registry/apps/index.js b/app/assets/javascripts/ml/model_registry/apps/index.js new file mode 100644 index 00000000000..f9e5f82e708 --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/apps/index.js @@ -0,0 +1,3 @@ +import ShowMlModel from './show_ml_model.vue'; + +export { ShowMlModel }; diff --git a/app/assets/javascripts/ml/model_registry/apps/show_ml_model.vue b/app/assets/javascripts/ml/model_registry/apps/show_ml_model.vue new file mode 100644 index 00000000000..d4f17c840d7 --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/apps/show_ml_model.vue @@ -0,0 +1,16 @@ +<script> +export default { + name: 'ShowMlModelApp', + components: {}, + props: { + model: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div>{{ model.name }}</div> +</template> diff --git a/app/assets/javascripts/ml/model_registry/routes/models/index/components/ml_models_index.vue b/app/assets/javascripts/ml/model_registry/routes/models/index/components/ml_models_index.vue index 37e5877ec52..3770b4ec3ac 100644 --- a/app/assets/javascripts/ml/model_registry/routes/models/index/components/ml_models_index.vue +++ b/app/assets/javascripts/ml/model_registry/routes/models/index/components/ml_models_index.vue @@ -1,17 +1,29 @@ <script> -import { GlLink } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; import * as translations from '~/ml/model_registry/routes/models/index/translations'; +import Pagination from '~/vue_shared/components/incubation/pagination.vue'; +import ModelRow from './model_row.vue'; export default { - name: 'MlExperimentsIndexApp', + name: 'MlModelRegistryApp', components: { - GlLink, + Pagination, + ModelRow, }, props: { models: { type: Array, required: true, }, + pageInfo: { + type: Object, + required: true, + }, + }, + computed: { + hasModels() { + return !isEmpty(this.models); + }, }, i18n: translations, }; @@ -27,8 +39,11 @@ export default { </div> </div> - <div v-for="model in models" :key="model.name"> - <gl-link :href="model.path"> {{ model.name }} / {{ model.version }} </gl-link> - </div> + <template v-if="hasModels"> + <model-row v-for="model in models" :key="model.name" :model="model" /> + <pagination v-bind="pageInfo" /> + </template> + + <p v-else class="gl-text-secondary">{{ $options.i18n.NO_MODELS_LABEL }}</p> </div> </template> diff --git a/app/assets/javascripts/ml/model_registry/routes/models/index/components/model_row.vue b/app/assets/javascripts/ml/model_registry/routes/models/index/components/model_row.vue new file mode 100644 index 00000000000..4f91f0939a8 --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/routes/models/index/components/model_row.vue @@ -0,0 +1,35 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import { modelVersionCountMessage } from '../translations'; + +export default { + name: 'MlModelRow', + components: { + GlLink, + }, + props: { + model: { + type: Object, + required: true, + }, + }, + computed: { + hasVersions() { + return this.model.version != null; + }, + }, + modelVersionCountMessage, +}; +</script> + +<template> + <div class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-py-3"> + <gl-link :href="model.path" class="gl-text-body gl-font-weight-bold gl-line-height-24"> + {{ model.name }} + </gl-link> + + <div class="gl-text-secondary"> + {{ $options.modelVersionCountMessage(model.version, model.versionCount) }} + </div> + </div> +</template> diff --git a/app/assets/javascripts/ml/model_registry/routes/models/index/translations.js b/app/assets/javascripts/ml/model_registry/routes/models/index/translations.js index f0f45f9424e..9210d816373 100644 --- a/app/assets/javascripts/ml/model_registry/routes/models/index/translations.js +++ b/app/assets/javascripts/ml/model_registry/routes/models/index/translations.js @@ -1,3 +1,16 @@ -import { s__ } from '~/locale'; +import { s__, n__, sprintf } from '~/locale'; -export const TITLE_LABEL = s__('MlExperimentTracking|Model registry'); +export const TITLE_LABEL = s__('MlModelRegistry|Model registry'); +export const NO_MODELS_LABEL = s__('MlModelRegistry|No models registered in this project'); + +export const modelVersionCountMessage = (version, versionCount) => { + if (!versionCount) return s__('MlModelRegistry|No registered versions'); + + const message = n__( + 'MlModelRegistry|%{version} · No other versions', + 'MlModelRegistry|%{version} · %{versionCount} versions', + versionCount, + ); + + return sprintf(message, { version, versionCount }); +}; diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js index 04167518d3f..265e2a2f880 100644 --- a/app/assets/javascripts/mr_notes/init_notes.js +++ b/app/assets/javascripts/mr_notes/init_notes.js @@ -44,6 +44,7 @@ export default () => { reportAbusePath: notesDataset.reportAbusePath, newCommentTemplatePath: notesDataset.newCommentTemplatePath, mrFilter: true, + newCustomEmojiPath: notesDataset.newCustomEmojiPath, }, data() { const noteableData = JSON.parse(notesDataset.noteableData); diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 144cfa4295b..329d6cfec00 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -13,10 +13,9 @@ import { slugifyWithUnderscore, } from '~/lib/utils/text_utility'; import { sprintf } from '~/locale'; -import { badgeState } from '~/merge_requests/components/merge_request_status_badge.vue'; +import { badgeState } from '~/merge_requests/components/merge_request_header.vue'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking'; import * as constants from '../constants'; @@ -49,7 +48,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagsMixin(), issuableStateMixin], + mixins: [issuableStateMixin], props: { noteableType: { type: String, @@ -69,7 +68,7 @@ export default { id: 'note-body', name: 'note[note]', class: 'js-note-text note-textarea js-gfm-input markdown-area', - 'data-qa-selector': 'comment_field', + 'data-testid': 'comment-field', }, }; }, @@ -361,7 +360,6 @@ export default { > <markdown-editor ref="markdownEditor" - :enable-content-editor="Boolean(glFeatures.contentEditorOnIssues)" :value="note" :render-markdown-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" diff --git a/app/assets/javascripts/notes/components/comment_type_dropdown.vue b/app/assets/javascripts/notes/components/comment_type_dropdown.vue index 2e4f925194f..f85b0de0c4e 100644 --- a/app/assets/javascripts/notes/components/comment_type_dropdown.vue +++ b/app/assets/javascripts/notes/components/comment_type_dropdown.vue @@ -108,7 +108,7 @@ export default { text: this.dropdownStartThreadButtonTitle, description: this.startDiscussionDescription, value: constants.DISCUSSION, - qaSelector: 'discussion_menu_item', + testid: 'discussion-menu-item', }, ]; }, @@ -132,7 +132,6 @@ export default { :data-track-label="trackingLabel" data-track-action="click_button" data-testid="comment-button" - data-qa-selector="comment_button" > <gl-button variant="confirm" :disabled="disabled" @click="handleClick"> {{ commentButtonTitle }} @@ -149,7 +148,7 @@ export default { @select="setNoteType" > <template #list-item="{ item }"> - <div :data-qa-selector="item.qaSelector"> + <div :data-testid="item.testid"> <strong>{{ item.text }}</strong> <p class="gl-m-0">{{ item.description }}</p> </div> diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index f08c005259c..efb6fc67806 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -1,5 +1,5 @@ <script> -import { GlSkeletonLoader } from '@gitlab/ui'; +import { GlButton, GlSkeletonLoader } from '@gitlab/ui'; // eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; @@ -19,6 +19,7 @@ export default { GlSkeletonLoader, DiffViewer, ImageDiffOverlay, + GlButton, }, directives: { SafeHtml, @@ -127,12 +128,12 @@ export default { <td class="new_line diff-line-num"></td> <td v-if="error" class="js-error-lazy-load-diff diff-loading-error-block"> {{ __('Unable to load the diff') }} - <button - class="gl-button btn-link btn-link-retry gl-p-0 js-toggle-lazy-diff-retry-button gl-reset-font-size!" + <gl-button + class="btn-link-retry gl-font-regular js-toggle-lazy-diff-retry-button" @click="fetchDiff" > {{ __('Try again') }} - </button> + </gl-button> </td> <td v-else class="line_content js-success-lazy-load"> <span></span> diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue index dcbf4a0e5d3..c68ffd73ecc 100644 --- a/app/assets/javascripts/notes/components/discussion_actions.vue +++ b/app/assets/javascripts/notes/components/discussion_actions.vue @@ -49,7 +49,7 @@ export default { <template> <div class="discussion-with-resolve-btn clearfix"> <reply-placeholder - data-qa-selector="discussion_reply_tab" + data-testid="discussion-reply-tab" :placeholder-text="__('Reply…')" @focus="$emit('showReplyForm')" /> @@ -58,7 +58,6 @@ export default { <div class="btn-group"> <resolve-discussion-button v-if="discussion.resolvable" - data-qa-selector="resolve_discussion_button" data-testid="resolve-discussion-button" :is-resolving="isResolving" :button-title="resolveButtonTitle" diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index d8883f90eda..b392ad55fa2 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -71,6 +71,9 @@ export default { return options; }, + isNotificationsTodosButtons() { + return this.glFeatures.notificationsTodosButtons && this.glFeatures.movedMrSidebar; + }, }, methods: { ...mapActions(['setExpandDiscussions']), @@ -92,10 +95,12 @@ export default { class="gl-display-flex discussions-counter" > <div - class="gl-display-flex gl-align-items-center gl-pl-4 gl-rounded-base gl-mr-3 gl-min-h-7" + class="gl-display-flex gl-align-items-center gl-pl-4 gl-rounded-base gl-min-h-7" :class="{ 'gl-bg-orange-50': blocksMerge && !allResolved, 'gl-bg-gray-50': !blocksMerge || allResolved, + 'gl-mr-3': !isNotificationsTodosButtons, + 'gl-mr-5': isNotificationsTodosButtons, }" data-testid="discussions-counter-text" > diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue index 90f7a6862f0..bf3a750cf40 100644 --- a/app/assets/javascripts/notes/components/discussion_filter.vue +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -175,7 +175,7 @@ export default { <gl-disclosure-dropdown id="discussion-preferences-dropdown" class="full-width-mobile" - data-qa-selector="discussion_preferences_dropdown" + data-testid="discussion-preferences-dropdown" :toggle-text="__('Sort or filter')" :disabled="isLoading" placement="right" @@ -213,7 +213,7 @@ export default { :is-selected="filter.value === currentValue" :class="{ 'is-active': filter.value === currentValue }" :data-filter-type="filterType(filter.value)" - data-qa-selector="filter_menu_item" + data-testid="filter-menu-item" @action="selectFilter(filter.value)" > <template #list-item> diff --git a/app/assets/javascripts/notes/components/discussion_filter_note.vue b/app/assets/javascripts/notes/components/discussion_filter_note.vue index d02327a37a7..bbfde7f2e0c 100644 --- a/app/assets/javascripts/notes/components/discussion_filter_note.vue +++ b/app/assets/javascripts/notes/components/discussion_filter_note.vue @@ -26,7 +26,7 @@ export default { <template> <li class="timeline-entry note note-wrapper discussion-filter-note js-discussion-filter-note" - data-qa-selector="discussion_filter_container" + data-testid="discussion-filter-container" > <div class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600" diff --git a/app/assets/javascripts/notes/components/email_participants_warning.vue b/app/assets/javascripts/notes/components/email_participants_warning.vue index cf9108992be..478c5847b41 100644 --- a/app/assets/javascripts/notes/components/email_participants_warning.vue +++ b/app/assets/javascripts/notes/components/email_participants_warning.vue @@ -1,11 +1,12 @@ <script> -import { GlSprintf } from '@gitlab/ui'; +import { GlSprintf, GlButton } from '@gitlab/ui'; import { toNounSeriesText } from '~/lib/utils/grammar'; import { s__, sprintf } from '~/locale'; export default { components: { GlSprintf, + GlButton, }, props: { emails: { @@ -58,9 +59,9 @@ export default { <div class="issuable-note-warning"> <gl-sprintf :message="message"> <template #andMore> - <button type="button" class="gl-button btn-link" @click="showMoreParticipants"> + <gl-button variant="link" class="gl-vertical-align-baseline" @click="showMoreParticipants"> {{ moreLabel }} - </button> + </gl-button> </template> <template #emails> <span>{{ title }}</span> diff --git a/app/assets/javascripts/notes/components/multiline_comment_form.vue b/app/assets/javascripts/notes/components/multiline_comment_form.vue index 2c2264c36f3..78097ff1033 100644 --- a/app/assets/javascripts/notes/components/multiline_comment_form.vue +++ b/app/assets/javascripts/notes/components/multiline_comment_form.vue @@ -88,7 +88,7 @@ export default { id="comment-line-start" :value="commentLineStart" :options="commentLineOptions" - size="sm" + width="sm" class="gl-w-auto gl-vertical-align-baseline" @change="updateCommentLineStart" /> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 7f23ee70086..5a1795d7479 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -337,7 +337,7 @@ export default { icon="pencil" category="tertiary" class="note-action-button js-note-edit gl-display-none gl-sm-display-block" - data-qa-selector="note_edit_button" + data-testid="note-edit-button" @click="onEdit" /> <gl-button diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 363383fd7ad..f8a0db93e37 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -5,7 +5,6 @@ import { mapGetters, mapActions, mapState } from 'vuex'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; -import glFeaturesFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking'; import eventHub from '../event_hub'; import issuableStateMixin from '../mixins/issuable_state'; @@ -24,7 +23,7 @@ export default { GlLink, GlFormCheckbox, }, - mixins: [issuableStateMixin, resolvable, glFeaturesFlagMixin()], + mixins: [issuableStateMixin, resolvable], props: { noteBody: { type: String, @@ -117,7 +116,7 @@ export default { 'aria-label': __('Reply to comment'), placeholder: this.$options.i18n.bodyPlaceholder, class: 'note-textarea js-gfm-input js-note-text markdown-area js-vue-issue-note-form', - 'data-qa-selector': 'reply_field', + 'data-testid': 'reply-field', }, }; }, @@ -202,6 +201,9 @@ export default { isDisabled() { return !this.updatedNoteBody.length || this.isSubmitting; }, + isInternalNote() { + return this.discussionNote.internal || this.discussion.confidential; + }, discussionNote() { const discussionNote = this.discussion.id ? this.getDiscussionLastNote(this.discussion) @@ -221,9 +223,6 @@ export default { placeholder: { link: ['startTag', 'endTag'] }, }; }, - enableContentEditor() { - return Boolean(this.glFeatures.contentEditorOnIssues); - }, codeSuggestionsConfig() { return { canSuggest: this.canSuggest, @@ -355,13 +354,9 @@ export default { </div> <div class="flash-container timeline-content"></div> <form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form"> - <comment-field-layout - :noteable-data="getNoteableData" - :is-internal-note="discussionNote.internal" - > + <comment-field-layout :noteable-data="getNoteableData" :is-internal-note="isInternalNote"> <markdown-editor ref="markdownEditor" - :enable-content-editor="enableContentEditor" :value="updatedNoteBody" :render-markdown-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" @@ -406,7 +401,7 @@ export default { category="primary" variant="confirm" class="gl-sm-mr-3 gl-mb-3" - data-qa-selector="start_review_button" + data-testid="start-review-button" @click="handleAddToReview" > <template v-if="hasDrafts">{{ __('Add to review') }}</template> @@ -416,7 +411,7 @@ export default { :disabled="isDisabled" category="secondary" variant="confirm" - data-qa-selector="comment_now_button" + data-testid="comment-now-button" class="gl-sm-mr-3 gl-mb-3 js-comment-button" @click="handleUpdate()" > @@ -439,7 +434,7 @@ export default { :disabled="isDisabled" category="primary" variant="confirm" - data-qa-selector="reply_comment_button" + data-testid="reply-comment-button" class="gl-sm-mr-3 gl-xs-mb-3 js-vue-issue-save js-comment-button" @click="handleUpdate()" > diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index bdf9ea2057c..c3701c01ee2 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -188,7 +188,10 @@ export default { v-text="authorName" ></span> </a> - <span v-if="!isSystemNote && !emailParticipant" class="text-nowrap author-username"> + <span + v-if="!isSystemNote && !emailParticipant" + class="text-nowrap author-username gl-text-truncate" + > <a ref="authorUsernameLink" class="author-username-link" @@ -205,7 +208,7 @@ export default { </template> <span v-else>{{ __('A deleted user') }}</span> <span class="note-headline-light note-headline-meta"> - <span class="system-note-message" data-qa-selector="system_note_content"> + <span class="system-note-message" data-testid="system-note-content"> <slot></slot> </span> <template v-if="createdAt"> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 94d5dc25b9e..e0b1f7a8c6a 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -310,7 +310,7 @@ export default { :data-discussion-resolvable="discussion.resolvable" :data-discussion-resolved="discussion.resolved" class="discussion js-discussion-container" - data-qa-selector="discussion_content" + data-testid="discussion-content" > <diff-discussion-header v-if="shouldRenderDiffs" :discussion="discussion" /> <div v-if="!shouldHideDiscussionBody" class="discussion-body"> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 9a7cc1a4d37..809b1716b91 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -421,7 +421,7 @@ export default { :data-award-url="note.toggle_award_path" :data-note-id="note.id" class="note note-wrapper note-comment" - data-qa-selector="noteable_note_container" + data-testid="noteable-note-container" > <div v-if="showMultiLineComment" diff --git a/app/assets/javascripts/notes/components/notes_activity_header.vue b/app/assets/javascripts/notes/components/notes_activity_header.vue index a91c825710d..ce642733396 100644 --- a/app/assets/javascripts/notes/components/notes_activity_header.vue +++ b/app/assets/javascripts/notes/components/notes_activity_header.vue @@ -38,7 +38,11 @@ export default { }, computed: { showAiActions() { - return this.resourceGlobalId && this.glFeatures.summarizeComments; + return ( + this.resourceGlobalId && + this.glFeatures.openaiExperimentation && + this.glFeatures.summarizeNotes + ); }, }, }; @@ -56,7 +60,7 @@ export default { :loading="aiLoading" /> <timeline-toggle v-if="showTimelineViewToggle" /> - <mr-discussion-filter v-if="mrFilter && glFeatures.mrActivityFilters" /> + <mr-discussion-filter v-if="mrFilter" /> <discussion-filter v-else :filters="notesFilters" :selected-value="notesFilterValue" /> </div> </div> diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue index a012b4411bc..981b9324688 100644 --- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue +++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue @@ -84,12 +84,7 @@ export default { :tooltip-text="author.name" tooltip-placement="bottom" /> - <gl-button - class="gl-mr-2" - variant="link" - data-qa-selector="expand_replies_button" - @click="toggle" - > + <gl-button class="gl-mr-2" variant="link" data-testid="expand-replies-button" @click="toggle"> {{ n__('%d reply', '%d replies', replies.length) }} </gl-button> <gl-sprintf :message="$options.i18n.lastReplyBy"> @@ -111,7 +106,7 @@ export default { v-else class="gl-text-body! gl-text-decoration-none!" variant="link" - data-qa-selector="collapse_replies_button" + data-testid="collapse-replies-button" @click="toggle" > {{ $options.i18n.collapseReplies }} diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 724b47bf44b..f9fbe6659ee 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -62,6 +62,7 @@ export default ({ editorAiActions = [] } = {}) => { newCommentTemplatePath: notesDataset.newCommentTemplatePath, resourceGlobalId: convertToGraphQLId(noteableData.noteableType, noteableData.id), editorAiActions: editorAiActions.map((factory) => factory(noteableData)), + newCustomEmojiPath: notesDataset.newCustomEmojiPath, }, data() { return { diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 7eb01897296..4071218d100 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -95,10 +95,7 @@ export const fetchDiscussions = ( ? { params: { notes_filter: filter, persist_filter: persistFilter } } : null; - if ( - window.gon?.features?.mrActivityFilters && - getters.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE - ) { + if (getters.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE) { config = { params: { notes_filter: 0, persist_filter: false } }; } diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index c43430639ad..62d991c2d9e 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -2,7 +2,7 @@ import { flattenDeep, clone } from 'lodash'; import { match } from '~/diffs/utils/diff_file'; import { isInMRPage } from '~/lib/utils/common_utils'; import { doesHashExistInUrl } from '~/lib/utils/url_utility'; -import { badgeState } from '~/merge_requests/components/merge_request_status_badge.vue'; +import { badgeState } from '~/merge_requests/components/merge_request_header.vue'; import * as constants from '../constants'; import { collapseSystemNotes } from './collapse_utils'; @@ -52,10 +52,7 @@ export const discussions = (state, getters, rootState) => { let discussionsInState = clone(state.discussions); // NOTE: not testing bc will be removed when backend is finished. - if ( - state.noteableData.targetType === 'merge_request' && - window.gon?.features?.mrActivityFilters - ) { + if (state.noteableData.targetType === 'merge_request') { discussionsInState = discussionsInState.reduce((acc, discussion) => { if (hideActivity(state.mergeRequestFilters, discussion)) { return acc; diff --git a/app/assets/javascripts/observability/client.js b/app/assets/javascripts/observability/client.js index 718001e98fe..2e976cd6230 100644 --- a/app/assets/javascripts/observability/client.js +++ b/app/assets/javascripts/observability/client.js @@ -140,7 +140,7 @@ function filterObjToQueryParams(filterObj) { let value = rawValue; if (filterName === 'durationMs') { // converting durationMs to duration_nano - value *= 1000; + value *= 1000000; } if (paramName && value) { @@ -166,28 +166,80 @@ function filterObjToQueryParams(filterObj) { * * @returns Array<Trace> : A list of traces */ -async function fetchTraces(tracingUrl, filters = {}) { - const filterParams = filterObjToQueryParams(filters); +async function fetchTraces(tracingUrl, { filters = {}, pageToken, pageSize } = {}) { + const params = filterObjToQueryParams(filters); + if (pageToken) { + params.append('page_token', pageToken); + } + if (pageSize) { + params.append('page_size', pageSize); + } try { const { data } = await axios.get(tracingUrl, { withCredentials: true, - params: filterParams, + params, }); if (!Array.isArray(data.traces)) { throw new Error('traces are missing/invalid in the response'); // eslint-disable-line @gitlab/require-i18n-strings } - return data.traces; + return data; + } catch (e) { + return reportErrorAndThrow(e); + } +} + +async function fetchServices(servicesUrl) { + try { + const { data } = await axios.get(servicesUrl, { + withCredentials: true, + }); + + if (!Array.isArray(data.services)) { + throw new Error('failed to fetch services. invalid response'); // eslint-disable-line @gitlab/require-i18n-strings + } + + return data.services; + } catch (e) { + return reportErrorAndThrow(e); + } +} + +async function fetchOperations(operationsUrl, serviceName) { + try { + if (!serviceName) { + throw new Error('fetchOperations() - serviceName is required.'); + } + if (!operationsUrl.includes('$SERVICE_NAME$')) { + throw new Error('fetchOperations() - operationsUrl must contain $SERVICE_NAME$'); + } + const url = operationsUrl.replace('$SERVICE_NAME$', serviceName); + const { data } = await axios.get(url, { + withCredentials: true, + }); + + if (!Array.isArray(data.operations)) { + throw new Error('failed to fetch operations. invalid response'); // eslint-disable-line @gitlab/require-i18n-strings + } + + return data.operations; } catch (e) { return reportErrorAndThrow(e); } } -export function buildClient({ provisioningUrl, tracingUrl }) { +export function buildClient({ provisioningUrl, tracingUrl, servicesUrl, operationsUrl } = {}) { + if (!provisioningUrl || !tracingUrl || !servicesUrl || !operationsUrl) { + throw new Error( + 'missing required params. provisioningUrl, tracingUrl, servicesUrl, operationsUrl are required', + ); + } return { enableTraces: () => enableTraces(provisioningUrl), isTracingEnabled: () => isTracingEnabled(provisioningUrl), fetchTraces: (filters) => fetchTraces(tracingUrl, filters), fetchTrace: (traceId) => fetchTrace(tracingUrl, traceId), + fetchServices: () => fetchServices(servicesUrl), + fetchOperations: (serviceName) => fetchOperations(operationsUrl, serviceName), }; } diff --git a/app/assets/javascripts/observability/components/observability_app.vue b/app/assets/javascripts/observability/components/observability_app.vue deleted file mode 100644 index 36cbe715149..00000000000 --- a/app/assets/javascripts/observability/components/observability_app.vue +++ /dev/null @@ -1,87 +0,0 @@ -<script> -import { darkModeEnabled } from '~/lib/utils/color_utils'; -import { setUrlParams } from '~/lib/utils/url_utility'; - -import { MESSAGE_EVENT_TYPE, FULL_APP_DIMENSIONS } from '../constants'; -import ObservabilitySkeleton from './skeleton/index.vue'; - -export default { - components: { - ObservabilitySkeleton, - }, - props: { - observabilityIframeSrc: { - type: String, - required: true, - }, - inlineEmbed: { - type: Boolean, - required: false, - default: false, - }, - skeletonVariant: { - type: String, - required: false, - default: 'dashboards', - }, - height: { - type: String, - required: false, - default: FULL_APP_DIMENSIONS.HEIGHT, - }, - width: { - type: String, - required: false, - default: FULL_APP_DIMENSIONS.WIDTH, - }, - }, - computed: { - iframeSrcWithParams() { - return `${setUrlParams( - { theme: darkModeEnabled() ? 'dark' : 'light', username: gon?.current_username }, - this.observabilityIframeSrc, - )}${this.inlineEmbed ? '&kiosk=inline-embed' : ''}`; - }, - }, - 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 { - data: { type, payload }, - } = e; - switch (type) { - case MESSAGE_EVENT_TYPE.GOUI_LOADED: - this.$refs.observabilitySkeleton.onContentLoaded(); - break; - case MESSAGE_EVENT_TYPE.GOUI_ROUTE_UPDATE: - this.$emit('route-update', payload); - break; - default: - break; - } - }, - }, -}; -</script> - -<template> - <observability-skeleton ref="observabilitySkeleton" :variant="skeletonVariant"> - <iframe - id="observability-ui-iframe" - data-testid="observability-ui-iframe" - frameborder="0" - :width="width" - :height="height" - :src="iframeSrcWithParams" - sandbox="allow-same-origin allow-forms allow-scripts" - ></iframe> - </observability-skeleton> -</template> diff --git a/app/assets/javascripts/observability/components/observability_container.vue b/app/assets/javascripts/observability/components/observability_container.vue index b7697cea299..1518c132560 100644 --- a/app/assets/javascripts/observability/components/observability_container.vue +++ b/app/assets/javascripts/observability/components/observability_container.vue @@ -13,11 +13,19 @@ export default { type: String, required: true, }, + provisioningUrl: { + type: String, + required: true, + }, tracingUrl: { type: String, required: true, }, - provisioningUrl: { + servicesUrl: { + type: String, + required: true, + }, + operationsUrl: { type: String, required: true, }, @@ -58,6 +66,8 @@ export default { this.observabilityClient = buildClient({ provisioningUrl: this.provisioningUrl, tracingUrl: this.tracingUrl, + servicesUrl: this.servicesUrl, + operationsUrl: this.operationsUrl, }); this.$refs.observabilitySkeleton?.onContentLoaded(); } else if (status === 'error') { diff --git a/app/assets/javascripts/observability/components/skeleton/dashboards.vue b/app/assets/javascripts/observability/components/skeleton/dashboards.vue deleted file mode 100644 index 887a0a9f094..00000000000 --- a/app/assets/javascripts/observability/components/skeleton/dashboards.vue +++ /dev/null @@ -1,30 +0,0 @@ -<!-- eslint-disable vue/multi-word-component-names --> -<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/embed.vue b/app/assets/javascripts/observability/components/skeleton/embed.vue deleted file mode 100644 index 965beb168bf..00000000000 --- a/app/assets/javascripts/observability/components/skeleton/embed.vue +++ /dev/null @@ -1,16 +0,0 @@ -<!-- eslint-disable vue/multi-word-component-names --> -<script> -import { GlSkeletonLoader } from '@gitlab/ui'; - -export default { - components: { - GlSkeletonLoader, - }, -}; -</script> -<template> - <gl-skeleton-loader> - <rect y="5" width="400" height="30" rx="2" ry="2" /> - <rect y="50" width="400" height="80" 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 deleted file mode 100644 index 3f748086eef..00000000000 --- a/app/assets/javascripts/observability/components/skeleton/explore.vue +++ /dev/null @@ -1,28 +0,0 @@ -<!-- eslint-disable vue/multi-word-component-names --> -<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 index d3c6892df50..c3d0a7c90b1 100644 --- a/app/assets/javascripts/observability/components/skeleton/index.vue +++ b/app/assets/javascripts/observability/components/skeleton/index.vue @@ -3,34 +3,20 @@ import { GlSkeletonLoader, GlAlert, GlLoadingIcon } from '@gitlab/ui'; import { - SKELETON_VARIANTS_BY_ROUTE, SKELETON_STATE, DEFAULT_TIMERS, - OBSERVABILITY_ROUTES, TIMEOUT_ERROR_LABEL, TIMEOUT_ERROR_MESSAGE, - SKELETON_VARIANT_EMBED, SKELETON_SPINNER_VARIANT, } from '../../constants'; -import DashboardsSkeleton from './dashboards.vue'; -import ExploreSkeleton from './explore.vue'; -import ManageSkeleton from './manage.vue'; -import EmbedSkeleton from './embed.vue'; export default { components: { GlSkeletonLoader, - DashboardsSkeleton, - ExploreSkeleton, - ManageSkeleton, - EmbedSkeleton, GlAlert, GlLoadingIcon, }, - SKELETON_VARIANTS_BY_ROUTE, SKELETON_STATE, - OBSERVABILITY_ROUTES, - SKELETON_VARIANT_EMBED, i18n: { TIMEOUT_ERROR_LABEL, TIMEOUT_ERROR_MESSAGE, @@ -62,9 +48,6 @@ export default { spinnerVariant() { return this.variant === SKELETON_SPINNER_VARIANT; }, - embedVariant() { - return this.variant === SKELETON_VARIANT_EMBED; - }, }, mounted() { this.setLoadingTimeout(); @@ -118,9 +101,6 @@ export default { showError() { this.state = SKELETON_STATE.ERROR; }, - isVariantByRoute(route) { - return this.variant === SKELETON_VARIANTS_BY_ROUTE[route]; - }, }, }; </script> @@ -128,12 +108,7 @@ export default { <div class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch"> <transition name="fade"> <div v-if="skeletonVisible" class="gl-px-5 gl-my-5"> - <dashboards-skeleton v-if="isVariantByRoute($options.OBSERVABILITY_ROUTES.DASHBOARDS)" /> - <explore-skeleton v-else-if="isVariantByRoute($options.OBSERVABILITY_ROUTES.EXPLORE)" /> - <manage-skeleton v-else-if="isVariantByRoute($options.OBSERVABILITY_ROUTES.MANAGE)" /> - <embed-skeleton v-else-if="embedVariant" /> - <gl-loading-icon v-else-if="spinnerVariant" size="lg" /> - + <gl-loading-icon v-if="spinnerVariant" size="lg" /> <gl-skeleton-loader v-else> <rect y="2" width="10" height="8" /> <rect y="2" x="15" width="15" height="8" /> diff --git a/app/assets/javascripts/observability/components/skeleton/manage.vue b/app/assets/javascripts/observability/components/skeleton/manage.vue deleted file mode 100644 index cf8c900fe11..00000000000 --- a/app/assets/javascripts/observability/components/skeleton/manage.vue +++ /dev/null @@ -1,26 +0,0 @@ -<!-- eslint-disable vue/multi-word-component-names --> -<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 index b0a0941779d..83eaea185e5 100644 --- a/app/assets/javascripts/observability/constants.js +++ b/app/assets/javascripts/observability/constants.js @@ -1,23 +1,5 @@ import { __ } from '~/locale'; -export const MESSAGE_EVENT_TYPE = Object.freeze({ - GOUI_LOADED: 'GOUI_LOADED', - GOUI_ROUTE_UPDATE: 'GOUI_ROUTE_UPDATE', -}); - -export const OBSERVABILITY_ROUTES = Object.freeze({ - DASHBOARDS: 'observability/dashboards', - EXPLORE: 'observability/explore', - MANAGE: 'observability/manage', -}); - -export const SKELETON_VARIANTS_BY_ROUTE = Object.freeze({ - [OBSERVABILITY_ROUTES.DASHBOARDS]: 'dashboards', - [OBSERVABILITY_ROUTES.EXPLORE]: 'explore', - [OBSERVABILITY_ROUTES.MANAGE]: 'manage', -}); - -export const SKELETON_VARIANT_EMBED = 'embed'; export const SKELETON_SPINNER_VARIANT = 'spinner'; export const SKELETON_STATE = Object.freeze({ @@ -33,13 +15,3 @@ export const DEFAULT_TIMERS = Object.freeze({ export const TIMEOUT_ERROR_LABEL = __('Unable to load the page'); export const TIMEOUT_ERROR_MESSAGE = __('Reload the page to try again.'); - -export const INLINE_EMBED_DIMENSIONS = Object.freeze({ - HEIGHT: '366px', - WIDTH: '768px', -}); - -export const FULL_APP_DIMENSIONS = Object.freeze({ - HEIGHT: '100%', - WIDTH: '100%', -}); diff --git a/app/assets/javascripts/observability/index.js b/app/assets/javascripts/observability/index.js deleted file mode 100644 index 72ff1357551..00000000000 --- a/app/assets/javascripts/observability/index.js +++ /dev/null @@ -1,60 +0,0 @@ -import Vue from 'vue'; -import VueRouter from 'vue-router'; - -import ObservabilityApp from './components/observability_app.vue'; -import { SKELETON_VARIANTS_BY_ROUTE } from './constants'; - -Vue.use(VueRouter); - -export default () => { - const el = document.getElementById('js-observability-app'); - - if (!el) return false; - - const router = new VueRouter({ - mode: 'history', - }); - - return new Vue({ - el, - router, - computed: { - skeletonVariant() { - const [, variant] = - Object.entries(SKELETON_VARIANTS_BY_ROUTE).find(([path]) => - this.$route.path.endsWith(path), - ) || []; - - return variant; - }, - }, - methods: { - routeUpdateHandler(payload) { - const isNewObservabilityPath = this.$route?.query?.observability_path !== payload?.url; - - const shouldNotHandleMessage = !payload.url || !isNewObservabilityPath; - - if (shouldNotHandleMessage) { - return; - } - - // 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: payload.url }, - }); - }, - }, - render(h) { - return h(ObservabilityApp, { - props: { - observabilityIframeSrc: el.dataset.observabilityIframeSrc, - skeletonVariant: this.skeletonVariant, - }, - on: { - 'route-update': (payload) => this.routeUpdateHandler(payload), - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/observability/mock_traces.json b/app/assets/javascripts/observability/mock_traces.json deleted file mode 100644 index cd7dfb40af6..00000000000 --- a/app/assets/javascripts/observability/mock_traces.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "project_id": 123, - "traces": [ - { - "timestamp": "2023-08-07T15:03:32.199806Z", - "trace_id": "dabb7ae1-2501-8e57-18e1-30ab21a9ab19", - "service_name": "tracegentracegentracegenttracegentracegentracegent", - "operation": "lets-golets-golets-goletslets-golets-golets-golets", - "statusCode": "STATUS_CODE_UNSET", - "duration_nano": 100120000, - "spans": [ - { - "timestamp": "2023-08-07T15:03:32.199806Z", - "span_id": "A1FB81EB031B09E8", - "trace_id": "dabb7ae1-2501-8e57-18e1-30ab21a9ab19", - "service_name": "tracegentracegentracegentracegentracegentracegentracegentracegentracegentracegentracegentracegentracegen", - "operation": "lets-golets-golets-golets-golets-golets-golets-golets-golets-golets-golets-golets-go", - "duration_nano": 100120000, - "parent_span_id": "", - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-08-07T15:03:32.199871Z", - "span_id": "9C920500FE9C85E3", - "trace_id": "dabb7ae1-2501-8e57-18e1-30ab21a9ab19", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 100055000, - "parent_span_id": "A1FB81EB031B09E8", - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-08-07T15:03:53.199871Z", - "span_id": "FAKE", - "trace_id": "dabb7ae1-2501-8e57-18e1-30ab21a9ab19", - "service_name": "tracegen", - "operation": "okey-dokey", - "duration_nano": 50027500, - "parent_span_id": "9C920500FE9C85E3", - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-08-07T15:03:53.199871Z", - "span_id": "FAKE-2", - "trace_id": "dabb7ae1-2501-8e57-18e1-30ab21a9ab19", - "service_name": "fake-service-2", - "operation": "okey-dokey", - "duration_nano": 50027500, - "parent_span_id": "9C920500FE9C85E3", - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-08-07T15:04:13.199871Z", - "span_id": "FAKE-3", - "trace_id": "dabb7ae1-2501-8e57-18e1-30ab21a9ab19", - "service_name": "fake-service-3", - "operation": "okey-dokey", - "duration_nano": 30000000, - "parent_span_id": "FAKE-2", - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-08-07T15:04:13.199871Z", - "span_id": "FAKE-4", - "trace_id": "dabb7ae1-2501-8e57-18e1-30ab21a9ab19", - "service_name": "fake-service-4", - "operation": "okey-dokey", - "duration_nano": 25000000, - "parent_span_id": "FAKE-3", - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-08-07T15:04:13.199871Z", - "span_id": "FAKE-5", - "trace_id": "dabb7ae1-2501-8e57-18e1-30ab21a9ab19", - "service_name": "fake-service-5", - "operation": "okey-dokey", - "duration_nano": 10000000, - "parent_span_id": "FAKE-4", - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-08-07T15:04:13.199871Z", - "span_id": "FAKE-6", - "trace_id": "dabb7ae1-2501-8e57-18e1-30ab21a9ab19", - "service_name": "fake-service-6", - "operation": "okey-dokey", - "duration_nano": 10000000, - "parent_span_id": "FAKE-5", - "statusCode": "STATUS_CODE_UNSET" - }, - { - "timestamp": "2023-08-07T15:04:13.199871Z", - "span_id": "FAKE-7", - "trace_id": "dabb7ae1-2501-8e57-18e1-30ab21a9ab19", - "service_name": "fake-service-7", - "operation": "okey-dokey", - "duration_nano": 5000000, - "parent_span_id": "FAKE-6", - "statusCode": "STATUS_CODE_UNSET" - } - ], - "totalSpans": 5 - } - ], - "totalTraces": 50 -} diff --git a/app/assets/javascripts/organizations/index/components/app.vue b/app/assets/javascripts/organizations/index/components/app.vue new file mode 100644 index 00000000000..c47f4ed52c5 --- /dev/null +++ b/app/assets/javascripts/organizations/index/components/app.vue @@ -0,0 +1,61 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import { createAlert } from '~/alert'; +import organizationsQuery from '../graphql/organizations.query.graphql'; +import OrganizationsView from './organizations_view.vue'; + +export default { + name: 'OrganizationsIndexApp', + i18n: { + organizations: __('Organizations'), + newOrganization: s__('Organization|New organization'), + errorMessage: s__( + 'Organization|An error occurred loading user organizations. Please refresh the page to try again.', + ), + }, + components: { + GlButton, + OrganizationsView, + }, + inject: ['newOrganizationUrl'], + data() { + return { + organizations: [], + }; + }, + apollo: { + organizations: { + query: organizationsQuery, + update(data) { + return data.currentUser.organizations.nodes; + }, + error(error) { + createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); + }, + }, + }, + computed: { + showHeader() { + return this.loading || this.organizations.length; + }, + loading() { + return this.$apollo.queries.organizations.loading; + }, + }, +}; +</script> + +<template> + <section> + <div v-if="showHeader" class="gl-display-flex gl-align-items-center"> + <h1 class="gl-my-4 gl-font-size-h-display">{{ $options.i18n.organizations }}</h1> + <div class="gl-ml-auto"> + <gl-button :href="newOrganizationUrl" variant="confirm">{{ + $options.i18n.newOrganization + }}</gl-button> + </div> + </div> + <organizations-view :organizations="organizations" :loading="loading" /> + </section> +</template> diff --git a/app/assets/javascripts/organizations/index/components/organizations_list.vue b/app/assets/javascripts/organizations/index/components/organizations_list.vue new file mode 100644 index 00000000000..539a4fcfe29 --- /dev/null +++ b/app/assets/javascripts/organizations/index/components/organizations_list.vue @@ -0,0 +1,26 @@ +<script> +import OrganizationsListItem from './organizations_list_item.vue'; + +export default { + name: 'OrganizationsList', + components: { + OrganizationsListItem, + }, + props: { + organizations: { + type: Array, + required: true, + }, + }, +}; +</script> + +<template> + <ul class="gl-p-0 gl-list-style-none"> + <organizations-list-item + v-for="organization in organizations" + :key="organization.id" + :organization="organization" + /> + </ul> +</template> diff --git a/app/assets/javascripts/organizations/index/components/organizations_list_item.vue b/app/assets/javascripts/organizations/index/components/organizations_list_item.vue new file mode 100644 index 00000000000..589835874ad --- /dev/null +++ b/app/assets/javascripts/organizations/index/components/organizations_list_item.vue @@ -0,0 +1,54 @@ +<script> +import { GlAvatarLabeled, GlTruncateText } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import SafeHtml from '~/vue_shared/directives/safe_html'; + +export default { + name: 'OrganizationsListItem', + components: { + GlAvatarLabeled, + GlTruncateText, + }, + safeHtmlConfig: { + ADD_TAGS: ['gl-emoji'], + }, + directives: { + SafeHtml, + }, + props: { + organization: { + type: Object, + required: true, + }, + }, + avatarSize: { default: 32, md: 48 }, + getIdFromGraphQLId, +}; +</script> + +<template> + <li class="organization-row gl-py-3 gl-border-b gl-display-flex gl-align-items-flex-start"> + <gl-avatar-labeled + :size="$options.avatarSize" + :src="organization.avatarUrl" + :entity-id="$options.getIdFromGraphQLId(organization.id)" + :entity-name="organization.name" + :label="organization.name" + :label-link="organization.webUrl" + shape="rect" + > + <gl-truncate-text + v-if="organization.descriptionHtml" + :lines="2" + :mobile-lines="2" + class="gl-mt-2" + > + <div + v-safe-html:[$options.safeHtmlConfig]="organization.descriptionHtml" + data-testid="organization-description-html" + class="organization-description gl-text-secondary gl-font-sm" + ></div> + </gl-truncate-text> + </gl-avatar-labeled> + </li> +</template> diff --git a/app/assets/javascripts/organizations/index/components/organizations_view.vue b/app/assets/javascripts/organizations/index/components/organizations_view.vue new file mode 100644 index 00000000000..9720646bca3 --- /dev/null +++ b/app/assets/javascripts/organizations/index/components/organizations_view.vue @@ -0,0 +1,52 @@ +<script> +import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import OrganizationsList from './organizations_list.vue'; + +export default { + name: 'OrganizationsView', + i18n: { + emptyStateTitle: s__('Organization|Get started with organizations'), + emptyStateDescription: s__( + 'Organization|Create an organization to contain all of your groups and projects.', + ), + emptyStateButtonText: s__('Organization|New organization'), + }, + components: { + GlLoadingIcon, + OrganizationsList, + GlEmptyState, + }, + inject: ['newOrganizationUrl', 'organizationsEmptyStateSvgPath'], + props: { + organizations: { + type: Array, + required: false, + default: () => [], + }, + loading: { + type: Boolean, + required: false, + default: false, + }, + }, +}; +</script> + +<template> + <gl-loading-icon v-if="loading" class="gl-mt-5" size="md" /> + <organizations-list + v-else-if="organizations.length" + :organizations="organizations" + class="gl-border-t" + /> + <gl-empty-state + v-else + :svg-height="144" + :svg-path="organizationsEmptyStateSvgPath" + :title="$options.i18n.emptyStateTitle" + :description="$options.i18n.emptyStateDescription" + :primary-button-link="newOrganizationUrl" + :primary-button-text="$options.i18n.emptyStateButtonText" + /> +</template> diff --git a/app/assets/javascripts/organizations/index/graphql/organizations.query.graphql b/app/assets/javascripts/organizations/index/graphql/organizations.query.graphql new file mode 100644 index 00000000000..6090e2ec789 --- /dev/null +++ b/app/assets/javascripts/organizations/index/graphql/organizations.query.graphql @@ -0,0 +1,14 @@ +query getCurrentUserOrganizations { + currentUser { + id + organizations @client { + nodes { + id + name + descriptionHtml + avatarUrl + webUrl + } + } + } +} diff --git a/app/assets/javascripts/organizations/index/index.js b/app/assets/javascripts/organizations/index/index.js new file mode 100644 index 00000000000..7cbb9c9165d --- /dev/null +++ b/app/assets/javascripts/organizations/index/index.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import resolvers from '../shared/graphql/resolvers'; +import OrganizationsIndexApp from './components/app.vue'; + +export const initOrganizationsIndex = () => { + const el = document.getElementById('js-organizations-index'); + + if (!el) return false; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(resolvers), + }); + + const { newOrganizationUrl, organizationsEmptyStateSvgPath } = convertObjectPropsToCamelCase( + el.dataset, + ); + + return new Vue({ + el, + name: 'OrganizationsIndexRoot', + apolloProvider, + provide: { + newOrganizationUrl, + organizationsEmptyStateSvgPath, + }, + render(createElement) { + return createElement(OrganizationsIndexApp); + }, + }); +}; diff --git a/app/assets/javascripts/organizations/mock_data.js b/app/assets/javascripts/organizations/mock_data.js index 17ab7bd1d34..d281a0d8a1c 100644 --- a/app/assets/javascripts/organizations/mock_data.js +++ b/app/assets/javascripts/organizations/mock_data.js @@ -4,10 +4,34 @@ // https://gitlab.com/gitlab-org/gitlab/-/issues/420777 // https://gitlab.com/gitlab-org/gitlab/-/issues/421441 -export const organization = { - id: 'gid://gitlab/Organization/1', - __typename: 'Organization', -}; +export const organizations = [ + { + id: 'gid://gitlab/Organization/1', + name: 'My First Organization', + descriptionHtml: + '<p>This is where an organization can be explained in <strong>detail</strong></p>', + avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61', + webUrl: '/-/organizations/default', + __typename: 'Organization', + }, + { + id: 'gid://gitlab/Organization/2', + name: 'Vegetation Co.', + descriptionHtml: + '<p> Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolt Lorem ipsum dolor sit amet Lorem ipsum dolt Lorem ipsum dolor sit amet Lorem ipsum dolt Lorem ipsum dolor sit amet Lorem ipsum dolt Lorem ipsum dolor sit amet Lorem ipsum dolt Lorem ipsum dolor sit amet Lorem ipsum dolt Lorem ipsum dolor sit amet Lorem ipsum dolt Lorem ipsum dolor sit amet Lorem ipsum dolt Lorem ipsum dolor sit amet Lorem ipsum dolt<script>alert(1)</script></p>', + avatarUrl: null, + webUrl: '/-/organizations/default', + __typename: 'Organization', + }, + { + id: 'gid://gitlab/Organization/3', + name: 'Dude where is my car?', + descriptionHtml: null, + avatarUrl: null, + webUrl: '/-/organizations/default', + __typename: 'Organization', + }, +]; export const organizationProjects = { nodes: [ @@ -256,3 +280,11 @@ export const organizationGroups = { }, ], }; + +export const createOrganizationResponse = { + organization: { + name: 'Default', + path: '/-/organizations/default', + }, + errors: [], +}; diff --git a/app/assets/javascripts/organizations/new/components/app.vue b/app/assets/javascripts/organizations/new/components/app.vue new file mode 100644 index 00000000000..8f71fdfe68b --- /dev/null +++ b/app/assets/javascripts/organizations/new/components/app.vue @@ -0,0 +1,82 @@ +<script> +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { visitUrlWithAlerts } from '~/lib/utils/url_utility'; +import { createAlert } from '~/alert'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import createOrganizationMutation from '../graphql/mutations/create_organization.mutation.graphql'; +import NewEditForm from '../../shared/components/new_edit_form.vue'; + +export default { + name: 'OrganizationNewApp', + components: { NewEditForm, GlSprintf, GlLink }, + i18n: { + pageTitle: s__('Organization|New organization'), + pageDescription: s__( + 'Organization|%{linkStart}Organizations%{linkEnd} are a top-level container to hold your groups and projects.', + ), + errorMessage: s__('Organization|An error occurred creating an organization. Please try again.'), + successAlertTitle: s__('Organization|Organization successfully created.'), + successAlertMessage: s__('Organization|You can now start using your new organization.'), + }, + data() { + return { + loading: false, + }; + }, + computed: { + organizationsHelpPagePath() { + return helpPagePath('user/organization/index'); + }, + }, + methods: { + async onSubmit(formValues) { + this.loading = true; + try { + const { + data: { + createOrganization: { organization, errors }, + }, + } = await this.$apollo.mutate({ + mutation: createOrganizationMutation, + variables: { + ...formValues, + }, + }); + + if (errors.length) { + // TODO: handle errors when using real API after https://gitlab.com/gitlab-org/gitlab/-/issues/417891 is complete. + return; + } + + visitUrlWithAlerts(organization.path, [ + { + id: 'organization-successfully-created', + title: this.$options.i18n.successAlertTitle, + message: this.$options.i18n.successAlertMessage, + variant: 'success', + }, + ]); + } catch (error) { + createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); + } finally { + this.loading = false; + } + }, + }, +}; +</script> + +<template> + <div class="gl-py-6"> + <h1 class="gl-mt-0 gl-font-size-h-display">{{ $options.i18n.pageTitle }}</h1> + <p> + <gl-sprintf :message="$options.i18n.pageDescription"> + <template #link="{ content }" + ><gl-link :href="organizationsHelpPagePath">{{ content }}</gl-link></template + > + </gl-sprintf> + </p> + <new-edit-form :loading="loading" @submit="onSubmit" /> + </div> +</template> diff --git a/app/assets/javascripts/organizations/new/graphql/mutations/create_organization.mutation.graphql b/app/assets/javascripts/organizations/new/graphql/mutations/create_organization.mutation.graphql new file mode 100644 index 00000000000..766c7e96d14 --- /dev/null +++ b/app/assets/javascripts/organizations/new/graphql/mutations/create_organization.mutation.graphql @@ -0,0 +1,9 @@ +mutation createOrganization($input: LocalCreateOrganizationInput!) { + createOrganization(input: $input) @client { + organization { + name + path + } + errors + } +} diff --git a/app/assets/javascripts/organizations/new/graphql/typedefs.graphql b/app/assets/javascripts/organizations/new/graphql/typedefs.graphql new file mode 100644 index 00000000000..f708c4ad162 --- /dev/null +++ b/app/assets/javascripts/organizations/new/graphql/typedefs.graphql @@ -0,0 +1,5 @@ +# TODO: Use real input type when https://gitlab.com/gitlab-org/gitlab/-/issues/417891 is complete. +input LocalCreateOrganizationInput { + name: String + path: String +} diff --git a/app/assets/javascripts/organizations/new/index.js b/app/assets/javascripts/organizations/new/index.js new file mode 100644 index 00000000000..a65603227f6 --- /dev/null +++ b/app/assets/javascripts/organizations/new/index.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; + +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import createDefaultClient from '~/lib/graphql'; +import resolvers from '../shared/graphql/resolvers'; +import App from './components/app.vue'; + +export const initOrganizationsNew = () => { + const el = document.getElementById('js-organizations-new'); + + if (!el) return false; + + const { + dataset: { appData }, + } = el; + const { organizationsPath, rootUrl } = convertObjectPropsToCamelCase(JSON.parse(appData)); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(resolvers), + }); + + return new Vue({ + el, + name: 'OrganizationNewRoot', + apolloProvider, + provide: { + organizationsPath, + rootUrl, + }, + render(createElement) { + return createElement(App); + }, + }); +}; diff --git a/app/assets/javascripts/organizations/shared/components/new_edit_form.vue b/app/assets/javascripts/organizations/shared/components/new_edit_form.vue new file mode 100644 index 00000000000..db33f240966 --- /dev/null +++ b/app/assets/javascripts/organizations/shared/components/new_edit_form.vue @@ -0,0 +1,125 @@ +<script> +import { + GlForm, + GlFormFields, + GlButton, + GlFormInputGroup, + GlFormInput, + GlInputGroupText, + GlTruncate, +} from '@gitlab/ui'; +import { formValidators } from '@gitlab/ui/dist/utils'; +import { s__, __ } from '~/locale'; +import { slugify } from '~/lib/utils/text_utility'; +import { joinPaths } from '~/lib/utils/url_utility'; + +export default { + name: 'NewEditForm', + components: { + GlForm, + GlFormFields, + GlButton, + GlFormInputGroup, + GlFormInput, + GlInputGroupText, + GlTruncate, + }, + i18n: { + createOrganization: s__('Organization|Create organization'), + cancel: __('Cancel'), + pathPlaceholder: s__('Organization|my-organization'), + }, + formId: 'new-organization-form', + fields: { + name: { + label: s__('Organization|Organization name'), + validators: [formValidators.required(s__('Organization|Organization name is required.'))], + groupAttrs: { + description: s__( + 'Organization|Must start with a letter, digit, emoji, or underscore. Can also contain periods, dashes, spaces, and parentheses.', + ), + }, + inputAttrs: { + class: 'gl-md-form-input-lg', + placeholder: s__('Organization|My organization'), + }, + }, + path: { + label: s__('Organization|Organization URL'), + validators: [formValidators.required(s__('Organization|Organization URL is required.'))], + }, + }, + inject: ['organizationsPath', 'rootUrl'], + props: { + loading: { + type: Boolean, + required: true, + }, + }, + data() { + return { + formValues: { + name: '', + path: '', + }, + hasPathBeenManuallySet: false, + }; + }, + computed: { + baseUrl() { + return joinPaths(this.rootUrl, this.organizationsPath, '/'); + }, + }, + watch: { + 'formValues.name': function watchName(value) { + if (this.hasPathBeenManuallySet) { + return; + } + + this.formValues.path = slugify(value); + }, + }, + methods: { + onPathInput(event, formFieldsInputEvent) { + formFieldsInputEvent(event); + this.hasPathBeenManuallySet = true; + }, + }, +}; +</script> + +<template> + <gl-form :id="$options.formId"> + <gl-form-fields + v-model="formValues" + :form-id="$options.formId" + :fields="$options.fields" + @submit="$emit('submit', formValues)" + > + <template #input(path)="{ id, value, validation, input, blur }"> + <gl-form-input-group> + <template #prepend> + <gl-input-group-text class="organization-root-path"> + <gl-truncate :text="baseUrl" position="middle" /> + </gl-input-group-text> + </template> + <gl-form-input + v-bind="validation" + :id="id" + :value="value" + :placeholder="$options.i18n.pathPlaceholder" + class="gl-h-auto! gl-md-form-input-lg" + @input="onPathInput($event, input)" + @blur="blur" + /> + </gl-form-input-group> + </template> + </gl-form-fields> + <div class="gl-display-flex gl-gap-3"> + <gl-button type="submit" variant="confirm" class="js-no-auto-disable" :loading="loading">{{ + $options.i18n.createOrganization + }}</gl-button> + <gl-button :href="organizationsPath">{{ $options.i18n.cancel }}</gl-button> + </div> + </gl-form> +</template> diff --git a/app/assets/javascripts/organizations/shared/graphql/resolvers.js b/app/assets/javascripts/organizations/shared/graphql/resolvers.js index c78266b0476..9f7e9b22e1d 100644 --- a/app/assets/javascripts/organizations/shared/graphql/resolvers.js +++ b/app/assets/javascripts/organizations/shared/graphql/resolvers.js @@ -1,18 +1,44 @@ -import { organization, organizationProjects, organizationGroups } from '../../mock_data'; +import { + organizations, + organizationProjects, + organizationGroups, + createOrganizationResponse, +} from '../../mock_data'; + +const simulateLoading = () => { + return new Promise((resolve) => { + setTimeout(resolve, 1000); + }); +}; export default { Query: { organization: async () => { // Simulate API loading - await new Promise((resolve) => { - setTimeout(resolve, 1000); - }); + await simulateLoading(); return { - ...organization, + ...organizations[0], projects: organizationProjects, groups: organizationGroups, }; }, }, + UserCore: { + organizations: async () => { + await simulateLoading(); + + return { + nodes: organizations, + }; + }, + }, + Mutation: { + createOrganization: async () => { + // Simulate API loading + await simulateLoading(); + + return createOrganizationResponse; + }, + }, }; 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 b58e2249829..7c594a6c091 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 @@ -231,6 +231,7 @@ export default { v-if="hasNoTags" :title="emptyStateTitle" :svg-path="config.noContainersImage" + :svg-height="null" :description="emptyStateDescription" class="gl-mx-auto gl-my-0" /> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue index a68c4de5aa6..93bdb942faa 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue @@ -15,6 +15,7 @@ export default { <gl-empty-state :title="s__('ContainerRegistry|There are no container images available in this group')" :svg-path="config.noContainersImage" + :svg-height="null" > <template #description> <p> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue index 5aa04419ca0..4ddcaa5c9a7 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue @@ -41,6 +41,7 @@ export default { <gl-empty-state :title="s__('ContainerRegistry|There are no container images stored for this project')" :svg-path="config.noContainersImage" + :svg-height="null" > <template #description> <p> 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 c266dbf7e98..3eb1b2b4ba5 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 @@ -190,6 +190,7 @@ export default { :title="$options.i18n.MISSING_OR_DELETED_IMAGE_TITLE" :description="$options.i18n.MISSING_OR_DELETED_IMAGE_MESSAGE" :svg-path="config.noContainersImage" + :svg-height="null" class="gl-mx-auto gl-my-0" /> </div> 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 df87ee79111..a1c4d7ea1f2 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 @@ -251,6 +251,7 @@ export default { v-if="showConnectionError" :title="$options.i18n.CONNECTION_ERROR_TITLE" :svg-path="config.containersErrorImage" + :svg-height="null" > <template #description> <p> @@ -325,6 +326,7 @@ export default { <gl-empty-state v-else :svg-path="config.noContainersImage" + :svg-height="null" data-testid="emptySearch" :title="$options.i18n.EMPTY_RESULT_TITLE" > diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_empty_state.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_empty_state.vue index b0d03a7cebe..7a29cb2d5ab 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_empty_state.vue +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_empty_state.vue @@ -35,7 +35,11 @@ export default { </script> <template> - <gl-empty-state :svg-path="noManifestsIllustration" :title="$options.i18n.noManifestTitle"> + <gl-empty-state + :svg-path="noManifestsIllustration" + :svg-height="null" + :title="$options.i18n.noManifestTitle" + > <template #description> <p class="gl-mb-5"> <gl-sprintf :message="$options.i18n.emptyText"> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list.vue index b55204de875..65ca4de7055 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list.vue +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list.vue @@ -73,6 +73,7 @@ export default { v-if="hasNoTags" :title="emptyStateTitle" :svg-path="noContainersImage" + :svg-height="null" :description="emptyStateDescription" class="gl-mx-auto gl-my-0" /> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list.vue index b34d3a950c0..ea265430865 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list.vue +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list.vue @@ -62,6 +62,7 @@ export default { v-else-if="hasNoTags" :title="emptyStateTitle" :svg-path="noContainersImage" + :svg-height="null" :description="emptyStateDescription" class="gl-mx-auto gl-my-0" /> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue index 1d8cb0f1360..9daed3e1211 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue @@ -164,6 +164,7 @@ export default { v-if="showConnectionError" :title="$options.i18n.connectionErrorTitle" :svg-path="containersErrorImage" + :svg-height="null" > <template #description> <p> @@ -220,6 +221,7 @@ export default { <gl-empty-state v-else :svg-path="noContainersImage" + :svg-height="null" data-testid="emptySearch" :title="emptyStateTexts.title" > diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue index cb96f3d96cb..b49c448c478 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue @@ -146,6 +146,7 @@ export default { :title="s__('PackageRegistry|Unable to load package')" :description="s__('PackageRegistry|There was a problem fetching the details for this package.')" :svg-path="svgPath" + :svg-height="null" /> <div v-else class="packages-app"> 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 d1982464eb9..265e3de0512 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 @@ -245,6 +245,7 @@ export default { :title="s__('PackageRegistry|Unable to load package')" :description="s__('PackageRegistry|There was a problem fetching the details for this package.')" :svg-path="emptyListIllustration" + :svg-height="null" /> <div v-else-if="projectName" class="packages-app"> <package-title :package-entity="packageEntity"> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/exceptions_input.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/exceptions_input.vue index 9ac1673dbf3..5a7feba35a4 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/exceptions_input.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/exceptions_input.vue @@ -70,7 +70,7 @@ export default { <gl-form-input :id="id" :disabled="duplicatesAllowed || loading" - size="lg" + width="lg" :value="duplicateExceptionRegex" :state="isExceptionRegexValid" @change="update(name, $event)" 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 cde46c3da50..cd6c9677b5f 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 @@ -268,7 +268,7 @@ export default { name="application_setting[signup_enabled]" :help-text="signupEnabledHelpText" :label="$options.i18n.signupEnabledLabel" - data-qa-selector="signup_enabled_checkbox" + data-testid="signup-enabled-checkbox" /> <signup-checkbox @@ -277,7 +277,6 @@ export default { name="application_setting[require_admin_approval_after_user_signup]" :help-text="requireAdminApprovalHelpText" :label="$options.i18n.requireAdminApprovalLabel" - data-qa-selector="require_admin_approval_after_user_signup_checkbox" data-testid="require-admin-approval-checkbox" /> @@ -452,7 +451,7 @@ export default { </section> <gl-button - data-qa-selector="save_changes_button" + data-testid="save-changes-button" variant="confirm" @click.prevent="submitButtonHandler" > diff --git a/app/assets/javascripts/pages/groups/custom_emoji/index.js b/app/assets/javascripts/pages/groups/custom_emoji/index.js new file mode 100644 index 00000000000..dd02a6f5348 --- /dev/null +++ b/app/assets/javascripts/pages/groups/custom_emoji/index.js @@ -0,0 +1,3 @@ +import { initCustomEmojis } from '~/custom_emoji/custom_emoji_bundle'; + +requestIdleCallback(initCustomEmojis); diff --git a/app/assets/javascripts/pages/groups/observability/dashboards/index.js b/app/assets/javascripts/pages/groups/observability/dashboards/index.js deleted file mode 100644 index c3b6ce6f99f..00000000000 --- a/app/assets/javascripts/pages/groups/observability/dashboards/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import ObservabilityApp from '~/observability'; - -ObservabilityApp(); diff --git a/app/assets/javascripts/pages/groups/observability/datasources/index.js b/app/assets/javascripts/pages/groups/observability/datasources/index.js deleted file mode 100644 index c3b6ce6f99f..00000000000 --- a/app/assets/javascripts/pages/groups/observability/datasources/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import ObservabilityApp from '~/observability'; - -ObservabilityApp(); diff --git a/app/assets/javascripts/pages/groups/observability/explore/index.js b/app/assets/javascripts/pages/groups/observability/explore/index.js deleted file mode 100644 index c3b6ce6f99f..00000000000 --- a/app/assets/javascripts/pages/groups/observability/explore/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import ObservabilityApp from '~/observability'; - -ObservabilityApp(); diff --git a/app/assets/javascripts/pages/groups/observability/manage/index.js b/app/assets/javascripts/pages/groups/observability/manage/index.js deleted file mode 100644 index c3b6ce6f99f..00000000000 --- a/app/assets/javascripts/pages/groups/observability/manage/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import ObservabilityApp from '~/observability'; - -ObservabilityApp(); diff --git a/app/assets/javascripts/pages/groups/work_items/show/index.js b/app/assets/javascripts/pages/groups/work_items/show/index.js new file mode 100644 index 00000000000..c091fbcc2b2 --- /dev/null +++ b/app/assets/javascripts/pages/groups/work_items/show/index.js @@ -0,0 +1,4 @@ +import { WORKSPACE_GROUP } from '~/issues/constants'; +import { initWorkItemsRoot } from '~/work_items'; + +initWorkItemsRoot(WORKSPACE_GROUP); diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue index 1d0eaae4c57..459546a5562 100644 --- a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue +++ b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue @@ -168,13 +168,22 @@ export default { } }, - getFullDestinationUrl(params) { + destinationLinkHref(params) { return joinPaths(gon.relative_url_root || '', '/', params.destination_full_path); }, - getPresentationUrl(item) { + pathWithSuffix(path, item) { const suffix = item.entity_type === WORKSPACE_GROUP ? '/' : ''; - return `${item.destination_full_path}${suffix}`; + return `${path}${suffix}`; + }, + + destinationLinkText(item) { + return this.pathWithSuffix(item.destination_full_path, item); + }, + + destinationText(item) { + const fullPath = joinPaths(item.destination_namespace, item.destination_slug); + return this.pathWithSuffix(fullPath, item); }, getEntityTooltip(item) { @@ -187,6 +196,11 @@ export default { return ''; } }, + + setPageSize(size) { + this.paginationConfig.perPage = size; + this.paginationConfig.page = 1; + }, }, gitlabLogo: window.gon.gitlab_logo, @@ -218,19 +232,21 @@ export default { class="gl-w-full" > <template #cell(destination_name)="{ item }"> - <template v-if="item.destination_full_path"> - <gl-icon - v-gl-tooltip - :name="item.entity_type" - :title="getEntityTooltip(item)" - :aria-label="getEntityTooltip(item)" - class="gl-text-gray-500" - /> - <gl-link :href="getFullDestinationUrl(item)" target="_blank"> - {{ getPresentationUrl(item) }} - </gl-link> - </template> - <gl-loading-icon v-else inline /> + <gl-icon + v-gl-tooltip + :name="item.entity_type" + :title="getEntityTooltip(item)" + :aria-label="getEntityTooltip(item)" + class="gl-text-gray-500" + /> + <gl-link + v-if="item.destination_full_path" + :href="destinationLinkHref(item)" + target="_blank" + > + {{ destinationLinkText(item) }} + </gl-link> + <span v-else>{{ destinationText(item) }}</span> </template> <template #cell(created_at)="{ value }"> <time-ago :time="value" /> @@ -253,7 +269,7 @@ export default { :page-info="pageInfo" class="gl-m-0 gl-mt-3" @set-page="paginationConfig.page = $event" - @set-page-size="paginationConfig.perPage = $event" + @set-page-size="setPageSize" /> </template> <local-storage-sync diff --git a/app/assets/javascripts/pages/organizations/organizations/index/index.js b/app/assets/javascripts/pages/organizations/organizations/index/index.js new file mode 100644 index 00000000000..c7e087b81c6 --- /dev/null +++ b/app/assets/javascripts/pages/organizations/organizations/index/index.js @@ -0,0 +1,3 @@ +import { initOrganizationsIndex } from '~/organizations/index'; + +initOrganizationsIndex(); diff --git a/app/assets/javascripts/pages/organizations/organizations/new/index.js b/app/assets/javascripts/pages/organizations/organizations/new/index.js new file mode 100644 index 00000000000..ab23fbf155d --- /dev/null +++ b/app/assets/javascripts/pages/organizations/organizations/new/index.js @@ -0,0 +1,3 @@ +import { initOrganizationsNew } from '~/organizations/new'; + +initOrganizationsNew(); diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index a3d930433c3..07662e4411e 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -21,6 +21,7 @@ import RefSelector from '~/ref/components/ref_selector.vue'; import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; import { parseBoolean } from '~/lib/utils/common_utils'; import HighlightWorker from '~/vue_shared/components/source_viewer/workers/highlight_worker?worker'; +import initAmbiguousRefModal from '~/ref/init_ambiguous_ref_modal'; Vue.use(Vuex); Vue.use(VueApollo); @@ -62,6 +63,7 @@ const initRefSwitcher = () => { }; initRefSwitcher(); +initAmbiguousRefModal(); if (viewBlobEl) { const { diff --git a/app/assets/javascripts/pages/projects/find_file/ref_switcher/index.js b/app/assets/javascripts/pages/projects/find_file/ref_switcher/index.js index 9a3bb25de70..ffd4ef9efbb 100644 --- a/app/assets/javascripts/pages/projects/find_file/ref_switcher/index.js +++ b/app/assets/javascripts/pages/projects/find_file/ref_switcher/index.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import { s__ } from '~/locale'; import Translate from '~/vue_shared/translate'; import RefSelector from '~/ref/components/ref_selector.vue'; -import { visitUrl } from '~/lib/utils/url_utility'; +import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; import { generateRefDestinationPath } from './ref_switcher_utils'; Vue.use(Translate); @@ -13,7 +13,7 @@ export default () => { const el = document.getElementById('js-blob-ref-switcher'); if (!el) return false; - const { projectId, ref, namespace } = el.dataset; + const { projectId, ref, refType, namespace } = el.dataset; return new Vue({ el, @@ -21,7 +21,8 @@ export default () => { return createElement(RefSelector, { props: { projectId, - value: ref, + value: refType ? joinPaths('refs', refType, ref) : ref, + useSymbolicRefNames: Boolean(refType), translations: { dropdownHeader: REF_SWITCH_HEADER, searchPlaceholder: REF_SWITCH_HEADER, diff --git a/app/assets/javascripts/pages/projects/find_file/ref_switcher/ref_switcher_utils.js b/app/assets/javascripts/pages/projects/find_file/ref_switcher/ref_switcher_utils.js index 5fecd024f1a..21a30f1c54b 100644 --- a/app/assets/javascripts/pages/projects/find_file/ref_switcher/ref_switcher_utils.js +++ b/app/assets/javascripts/pages/projects/find_file/ref_switcher/ref_switcher_utils.js @@ -10,19 +10,32 @@ export function generateRefDestinationPath(selectedRef, namespace) { return window.location.href; } + let refType = null; const { pathname } = window.location; const encodedHash = '%23'; const [projectRootPath] = pathname.split(namespace); + let actualRef = selectedRef; + + const matches = selectedRef.match(/^refs\/(heads|tags)\/(.+)/); + if (matches) { + [, refType, actualRef] = matches; + } const destinationPath = joinPaths( projectRootPath, namespace, - encodeURI(selectedRef).replace(/#/g, encodedHash), + encodeURI(actualRef).replace(/#/g, encodedHash), ); const newURL = new URL(window.location); newURL.pathname = destinationPath; + if (refType) { + newURL.searchParams.set('ref_type', refType.toLowerCase()); + } else { + newURL.searchParams.delete('ref_type'); + } + return newURL.href; } diff --git a/app/assets/javascripts/pages/projects/find_file/show/index.js b/app/assets/javascripts/pages/projects/find_file/show/index.js index e207df2434b..22c21430e8b 100644 --- a/app/assets/javascripts/pages/projects/find_file/show/index.js +++ b/app/assets/javascripts/pages/projects/find_file/show/index.js @@ -6,8 +6,9 @@ import InitBlobRefSwitcher from '../ref_switcher'; InitBlobRefSwitcher(); const findElement = document.querySelector('.js-file-finder'); const projectFindFile = new ProjectFindFile($('.file-finder-holder'), { - url: findElement.dataset.fileFindUrl, treeUrl: findElement.dataset.findTreeUrl, blobUrlTemplate: findElement.dataset.blobUrlTemplate, + refType: findElement.dataset.refType, }); +projectFindFile.load(findElement.dataset.fileFindUrl); new ShortcutsFindFile(projectFindFile); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js index 10c794c9ba2..c24a69bc26b 100644 --- a/app/assets/javascripts/pages/projects/graphs/charts/index.js +++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js @@ -1,6 +1,5 @@ import { GlColumnChart } from '@gitlab/ui/dist/charts'; import Vue from 'vue'; -import { waitForCSSLoaded } from '~/helpers/startup_css_helper'; import { __ } from '~/locale'; import { visitUrl } from '~/lib/utils/url_utility'; import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; @@ -10,205 +9,203 @@ import SeriesDataMixin from './series_data_mixin'; const seriesDataToBarData = (raw) => Object.entries(raw).map(([name, data]) => ({ name, data })); -waitForCSSLoaded(() => { - const languagesContainer = document.getElementById('js-languages-chart'); - const codeCoverageContainer = document.getElementById('js-code-coverage-chart'); - const monthContainer = document.getElementById('js-month-chart'); - const weekdayContainer = document.getElementById('js-weekday-chart'); - const hourContainer = document.getElementById('js-hour-chart'); - const branchSelector = document.getElementById('js-project-graph-ref-switcher'); - const LANGUAGE_CHART_HEIGHT = 300; - const reorderWeekDays = (weekDays, firstDayOfWeek = 0) => { - if (firstDayOfWeek === 0) { - return weekDays; - } +const languagesContainer = document.getElementById('js-languages-chart'); +const codeCoverageContainer = document.getElementById('js-code-coverage-chart'); +const monthContainer = document.getElementById('js-month-chart'); +const weekdayContainer = document.getElementById('js-weekday-chart'); +const hourContainer = document.getElementById('js-hour-chart'); +const branchSelector = document.getElementById('js-project-graph-ref-switcher'); +const LANGUAGE_CHART_HEIGHT = 300; +const reorderWeekDays = (weekDays, firstDayOfWeek = 0) => { + if (firstDayOfWeek === 0) { + return weekDays; + } - return Object.keys(weekDays).reduce((acc, dayName, idx, arr) => { - const reorderedDayName = arr[(idx + firstDayOfWeek) % arr.length]; + return Object.keys(weekDays).reduce((acc, dayName, idx, arr) => { + const reorderedDayName = arr[(idx + firstDayOfWeek) % arr.length]; - return { - ...acc, - [reorderedDayName]: weekDays[reorderedDayName], - }; - }, {}); - }; + return { + ...acc, + [reorderedDayName]: weekDays[reorderedDayName], + }; + }, {}); +}; - // eslint-disable-next-line no-new - new Vue({ - el: languagesContainer, - components: { - GlColumnChart, - }, - data() { - return { - chartData: JSON.parse(languagesContainer.dataset.chartData), - }; - }, - computed: { - seriesData() { - return [{ name: 'full', data: this.chartData.map((d) => [d.label, d.value]) }]; +// eslint-disable-next-line no-new +new Vue({ + el: languagesContainer, + components: { + GlColumnChart, + }, + data() { + return { + chartData: JSON.parse(languagesContainer.dataset.chartData), + }; + }, + computed: { + seriesData() { + return [{ name: 'full', data: this.chartData.map((d) => [d.label, d.value]) }]; + }, + }, + render(h) { + return h(GlColumnChart, { + props: { + bars: this.seriesData, + xAxisTitle: __('Used programming language'), + yAxisTitle: __('Percentage'), + xAxisType: 'category', }, - }, - render(h) { - return h(GlColumnChart, { - props: { - bars: this.seriesData, - xAxisTitle: __('Used programming language'), - yAxisTitle: __('Percentage'), - xAxisType: 'category', - }, - attrs: { - height: LANGUAGE_CHART_HEIGHT, - responsive: true, - }, - }); - }, - }); + attrs: { + height: LANGUAGE_CHART_HEIGHT, + responsive: true, + }, + }); + }, +}); - const { - graphEndpoint, - graphEndDate, - graphStartDate, - graphRef, - graphCsvPath, - } = codeCoverageContainer.dataset; - // eslint-disable-next-line no-new - new Vue({ - el: codeCoverageContainer, - render(h) { - return h(CodeCoverage, { - props: { - graphEndpoint, - graphEndDate, - graphStartDate, - graphRef, - graphCsvPath, - }, - }); - }, - }); +const { + graphEndpoint, + graphEndDate, + graphStartDate, + graphRef, + graphCsvPath, +} = codeCoverageContainer.dataset; +// eslint-disable-next-line no-new +new Vue({ + el: codeCoverageContainer, + render(h) { + return h(CodeCoverage, { + props: { + graphEndpoint, + graphEndDate, + graphStartDate, + graphRef, + graphCsvPath, + }, + }); + }, +}); - // eslint-disable-next-line no-new - new Vue({ - el: monthContainer, - components: { - GlColumnChart, - }, - mixins: [SeriesDataMixin], - data() { - return { - chartData: JSON.parse(monthContainer.dataset.chartData), - }; - }, - render(h) { - return h(GlColumnChart, { - props: { - bars: seriesDataToBarData(this.seriesData), - xAxisTitle: __('Day of month'), - yAxisTitle: __('No. of commits'), - xAxisType: 'category', - }, - attrs: { - responsive: true, - }, - }); - }, - }); +// eslint-disable-next-line no-new +new Vue({ + el: monthContainer, + components: { + GlColumnChart, + }, + mixins: [SeriesDataMixin], + data() { + return { + chartData: JSON.parse(monthContainer.dataset.chartData), + }; + }, + render(h) { + return h(GlColumnChart, { + props: { + bars: seriesDataToBarData(this.seriesData), + xAxisTitle: __('Day of month'), + yAxisTitle: __('No. of commits'), + xAxisType: 'category', + }, + attrs: { + responsive: true, + }, + }); + }, +}); - // eslint-disable-next-line no-new - new Vue({ - el: weekdayContainer, - components: { - GlColumnChart, - }, - data() { - return { - chartData: JSON.parse(weekdayContainer.dataset.chartData), - }; - }, - computed: { - seriesData() { - const weekDays = reorderWeekDays(this.chartData, gon.first_day_of_week); - const data = Object.keys(weekDays).reduce((acc, key) => { - acc.push([key, weekDays[key]]); - return acc; - }, []); - return [{ name: 'full', data }]; +// eslint-disable-next-line no-new +new Vue({ + el: weekdayContainer, + components: { + GlColumnChart, + }, + data() { + return { + chartData: JSON.parse(weekdayContainer.dataset.chartData), + }; + }, + computed: { + seriesData() { + const weekDays = reorderWeekDays(this.chartData, gon.first_day_of_week); + const data = Object.keys(weekDays).reduce((acc, key) => { + acc.push([key, weekDays[key]]); + return acc; + }, []); + return [{ name: 'full', data }]; + }, + }, + render(h) { + return h(GlColumnChart, { + props: { + bars: this.seriesData, + xAxisTitle: __('Weekday'), + yAxisTitle: __('No. of commits'), + xAxisType: 'category', }, - }, - render(h) { - return h(GlColumnChart, { - props: { - bars: this.seriesData, - xAxisTitle: __('Weekday'), - yAxisTitle: __('No. of commits'), - xAxisType: 'category', - }, - attrs: { - responsive: true, - }, - }); - }, - }); + attrs: { + responsive: true, + }, + }); + }, +}); - // eslint-disable-next-line no-new - new Vue({ - el: hourContainer, - components: { - GlColumnChart, - }, - mixins: [SeriesDataMixin], - data() { - return { - chartData: JSON.parse(hourContainer.dataset.chartData), - }; - }, - render(h) { - return h(GlColumnChart, { - props: { - bars: seriesDataToBarData(this.seriesData), - xAxisTitle: __('Hour (UTC)'), - yAxisTitle: __('No. of commits'), - xAxisType: 'category', - }, - attrs: { - responsive: true, - }, - }); - }, - }); +// eslint-disable-next-line no-new +new Vue({ + el: hourContainer, + components: { + GlColumnChart, + }, + mixins: [SeriesDataMixin], + data() { + return { + chartData: JSON.parse(hourContainer.dataset.chartData), + }; + }, + render(h) { + return h(GlColumnChart, { + props: { + bars: seriesDataToBarData(this.seriesData), + xAxisTitle: __('Hour (UTC)'), + yAxisTitle: __('No. of commits'), + xAxisType: 'category', + }, + attrs: { + responsive: true, + }, + }); + }, +}); - const { projectId, projectBranch, graphPath } = branchSelector.dataset; +const { projectId, projectBranch, graphPath } = branchSelector.dataset; - const GRAPHS_PATH_REGEX = /^(.*?)\/-\/graphs/g; - const graphsPathPrefix = graphPath.match(GRAPHS_PATH_REGEX)?.[0]; - if (!graphsPathPrefix) { - // eslint-disable-next-line @gitlab/require-i18n-strings - throw new Error('Path is not correct'); - } +const GRAPHS_PATH_REGEX = /^(.*?)\/-\/graphs/g; +const graphsPathPrefix = graphPath.match(GRAPHS_PATH_REGEX)?.[0]; +if (!graphsPathPrefix) { + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('Path is not correct'); +} - // eslint-disable-next-line no-new - new Vue({ - el: branchSelector, - name: 'RefSelector', - render(createComponent) { - return createComponent(RefSelector, { - props: { - enabledRefTypes: [REF_TYPE_BRANCHES, REF_TYPE_TAGS], - value: projectBranch, - translations: { - dropdownHeader: __('Switch branch/tag'), - searchPlaceholder: __('Search branches and tags'), - }, - projectId, +// eslint-disable-next-line no-new +new Vue({ + el: branchSelector, + name: 'RefSelector', + render(createComponent) { + return createComponent(RefSelector, { + props: { + enabledRefTypes: [REF_TYPE_BRANCHES, REF_TYPE_TAGS], + value: projectBranch, + translations: { + dropdownHeader: __('Switch branch/tag'), + searchPlaceholder: __('Search branches and tags'), }, - class: 'gl-w-20', - on: { - input(selected) { - visitUrl(`${graphsPathPrefix}/${encodeURIComponent(selected)}/charts`); - }, + projectId, + }, + class: 'gl-w-20', + on: { + input(selected) { + visitUrl(`${graphsPathPrefix}/${encodeURIComponent(selected)}/charts`); }, - }); - }, - }); + }, + }); + }, }); diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/index.js b/app/assets/javascripts/pages/projects/issues/service_desk/index.js index ead15143072..4118541d973 100644 --- a/app/assets/javascripts/pages/projects/issues/service_desk/index.js +++ b/app/assets/javascripts/pages/projects/issues/service_desk/index.js @@ -1,6 +1,4 @@ -import { initFilteredSearchServiceDesk } from '~/issues'; -import { mountServiceDeskListApp } from '~/issues/service_desk'; +import { initFilteredSearchServiceDesk, mountServiceDeskListApp } from '~/issues/service_desk'; initFilteredSearchServiceDesk(); - mountServiceDeskListApp(); diff --git a/app/assets/javascripts/pages/projects/jobs/show/index.js b/app/assets/javascripts/pages/projects/jobs/show/index.js index cd83f2b7b64..6618b68e9fe 100644 --- a/app/assets/javascripts/pages/projects/jobs/show/index.js +++ b/app/assets/javascripts/pages/projects/jobs/show/index.js @@ -1,3 +1,3 @@ -import initJobDetails from '~/ci/job_details'; +import { initJobDetails } from '~/ci/job_details'; initJobDetails(); 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 d23a0615bb8..8cb1462c883 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 @@ -43,6 +43,7 @@ if (mrNewCompareNode) { project: 'js-source-project', branch: 'js-source-branch gl-font-monospace', }, + compareSide: 'source', }, methods: { async selectedBranch(branchName) { diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js index 2cdbf0fb830..af1635221ab 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js @@ -4,8 +4,9 @@ import { s__ } from '~/locale'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import { initPipelineCountListener } from '~/commit/pipelines/utils'; import { initIssuableSidebar } from '~/issuable'; -import MergeRequestStatusBadge from '~/merge_requests/components/merge_request_status_badge.vue'; +import MergeRequestHeader from '~/merge_requests/components/merge_request_header.vue'; import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; import initSourcegraph from '~/sourcegraph'; import ZenMode from '~/zen_mode'; import initAwardsApp from '~/emoji/awards_app'; @@ -14,7 +15,7 @@ import { initMrExperienceSurvey } from '~/surveys/merge_request_experience'; import toast from '~/vue_shared/plugins/global_toast'; import getStateQuery from './queries/get_state.query.graphql'; -export default function initMergeRequestShow() { +export default function initMergeRequestShow(store) { new ZenMode(); // eslint-disable-line no-new initPipelineCountListener(document.querySelector('#commit-pipeline-table-view')); new ShortcutsIssuable(true); // eslint-disable-line no-new @@ -23,26 +24,27 @@ export default function initMergeRequestShow() { initAwardsApp(document.getElementById('js-vue-awards-block')); initMrExperienceSurvey(); - const el = document.querySelector('.js-mr-status-box'); - const { iid, issuableType, projectPath, state } = el.dataset; + const el = document.querySelector('.js-mr-header'); + const { hidden, iid, projectPath, state } = el.dataset; // eslint-disable-next-line no-new new Vue({ el, - name: 'IssuableStatusBoxRoot', + name: 'MergeRequestHeaderRoot', + store, apolloProvider: new VueApollo({ defaultClient: createDefaultClient(), }), provide: { query: getStateQuery, + hidden: parseBoolean(hidden), iid, projectPath, }, render(createElement) { - return createElement(MergeRequestStatusBadge, { + return createElement(MergeRequestHeader, { props: { initialState: state, - issuableType, }, }); }, diff --git a/app/assets/javascripts/pages/projects/merge_requests/page.js b/app/assets/javascripts/pages/projects/merge_requests/page.js index f7b522f7c85..fb243d01dc6 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/page.js +++ b/app/assets/javascripts/pages/projects/merge_requests/page.js @@ -1,7 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import initMrNotes from 'ee_else_ce/mr_notes'; -import { mountHeaderMetadata } from '~/merge_requests'; import StickyHeader from '~/merge_requests/components/sticky_header.vue'; import { start as startCodeReviewMessaging } from '~/code_review/signals'; import diffsEventHub from '~/diffs/event_hub'; @@ -17,14 +16,13 @@ Vue.use(VueApollo); export function initMrPage() { initMrNotes(); - initShow(); + initShow(store); initMrMoreDropdown(); startCodeReviewMessaging({ signalBus: diffsEventHub }); } requestIdleCallback(() => { initSidebarBundle(store); - mountHeaderMetadata(store); const el = document.getElementById('js-merge-sticky-header'); diff --git a/app/assets/javascripts/pages/projects/ml/models/show/index.js b/app/assets/javascripts/pages/projects/ml/models/show/index.js new file mode 100644 index 00000000000..87ee5c851f6 --- /dev/null +++ b/app/assets/javascripts/pages/projects/ml/models/show/index.js @@ -0,0 +1,4 @@ +import { initSimpleApp } from '~/helpers/init_simple_app_helper'; +import { ShowMlModel } from '~/ml/model_registry/apps'; + +initSimpleApp('#js-mount-show-ml-model', ShowMlModel); 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 642fd56eab1..9c4582ece21 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 @@ -1,12 +1,5 @@ <script> -import { - GlFormRadio, - GlFormRadioGroup, - GlIcon, - GlLink, - GlSprintf, - GlTooltipDirective, -} from '@gitlab/ui'; +import { GlFormRadio, GlFormRadioGroup, GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui'; import { getWeekdayNames } from '~/lib/utils/datetime_utility'; import { __, s__, sprintf } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -23,7 +16,6 @@ export default { GlFormRadioGroup, GlIcon, GlLink, - GlSprintf, }, directives: { GlTooltip: GlTooltipDirective, @@ -97,8 +89,7 @@ export default { }, { value: KEY_CUSTOM, - text: s__('PipelineScheduleIntervalPattern|Custom (%{linkStart}Learn more%{linkEnd}.)'), - link: this.cronSyntaxUrl, + text: s__('PipelineScheduleIntervalPattern|Custom'), }, ]; }, @@ -155,6 +146,10 @@ export default { return value === KEY_CUSTOM && this.dailyLimit; }, }, + i18n: { + learnCronSyntax: s__('PipelineScheduleIntervalPattern|Set a custom interval with Cron syntax.'), + cronSyntaxLink: s__('PipelineScheduleIntervalPattern|What is Cron syntax?'), + }, }; </script> @@ -167,19 +162,14 @@ export default { :value="option.value" :data-testid="option.value" > - <gl-sprintf v-if="option.link" :message="option.text"> - <template #link="{ content }"> - <gl-link :href="option.link" target="_blank" class="gl-font-sm">{{ content }}</gl-link> - </template> - </gl-sprintf> - - <template v-else>{{ option.text }}</template> + {{ option.text }} <gl-icon v-if="showDailyLimitMessage(option)" v-gl-tooltip.hover name="question-o" :title="scheduleDailyLimitMsg" + data-testid="daily-limit" /> </gl-form-radio> </gl-form-radio-group> @@ -193,5 +183,11 @@ export default { required="true" @input="onCustomInput" /> + <p class="gl-mt-1 gl-mb-0 gl-text-secondary"> + {{ $options.i18n.learnCronSyntax }} + <gl-link :href="cronSyntaxUrl" target="_blank"> + {{ $options.i18n.cronSyntaxLink }} + </gl-link> + </p> </div> </template> diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index bee0731d711..98c58515d24 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -7,6 +7,7 @@ import initTerraformNotification from '~/projects/terraform_notification'; import { initUploadFileTrigger } from '~/projects/upload_file'; import initReadMore from '~/read_more'; import initForksButton from '~/forks/init_forks_button'; +import initAmbiguousRefModal from '~/ref/init_ambiguous_ref_modal'; // Project show page loads different overview content based on user preferences if (document.getElementById('js-tree-list')) { @@ -45,6 +46,7 @@ initTerraformNotification(); initReadMore(); initStarButton(); +initAmbiguousRefModal(); if (document.querySelector('.js-autodevops-banner')) { import(/* webpackChunkName: 'userCallOut' */ '~/user_callout') diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js index 17c17014ece..d87f8898c63 100644 --- a/app/assets/javascripts/pages/projects/tree/show/index.js +++ b/app/assets/javascripts/pages/projects/tree/show/index.js @@ -2,7 +2,9 @@ import $ from 'jquery'; import initTree from 'ee_else_ce/repository'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import NewCommitForm from '~/new_commit_form'; +import initAmbiguousRefModal from '~/ref/init_ambiguous_ref_modal'; new NewCommitForm($('.js-create-dir-form')); // eslint-disable-line no-new initTree(); +initAmbiguousRefModal(); new ShortcutsNavigation(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/work_items/index.js b/app/assets/javascripts/pages/projects/work_items/index.js index 11c257611f0..b44ca708b28 100644 --- a/app/assets/javascripts/pages/projects/work_items/index.js +++ b/app/assets/javascripts/pages/projects/work_items/index.js @@ -1,3 +1,3 @@ -import { initWorkItemsRoot } from '~/work_items/index'; +import { initWorkItemsRoot } from '~/work_items'; initWorkItemsRoot(); diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js index 84050c3cb0f..90a9c9e7279 100644 --- a/app/assets/javascripts/pages/registrations/new/index.js +++ b/app/assets/javascripts/pages/registrations/new/index.js @@ -1,5 +1,3 @@ -import { trackNewRegistrations } from '~/google_tag_manager'; - import NoEmojiValidator from '~/emoji/no_emoji_validator'; import LengthValidator from '~/validators/length_validator'; import UsernameValidator from '~/pages/sessions/new/username_validator'; @@ -13,8 +11,6 @@ new LengthValidator(); // eslint-disable-line no-new new NoEmojiValidator(); // eslint-disable-line no-new new EmailFormatValidator(); // eslint-disable-line no-new -trackNewRegistrations(); - Tracking.enableFormTracking({ forms: { allow: ['new_user'] }, }); diff --git a/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue b/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue index 3792dad376b..3c070d2708d 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue @@ -77,7 +77,7 @@ export default { v-gl-modal="$options.modal.modalId" category="secondary" variant="danger" - data-qa-selector="delete_button" + data-qa-selector="delete-button" > {{ $options.i18n.deletePageText }} </gl-button> diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue index 553cb1f0464..eaa99556994 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -317,7 +317,7 @@ export default { name="wiki[title]" type="text" class="form-control" - data-qa-selector="wiki_title_textbox" + data-testid="wiki-title-textbox" :required="true" :autofocus="!pageInfo.persisted" :placeholder="$options.i18n.title.placeholder" @@ -397,7 +397,7 @@ export default { name="wiki[message]" type="text" class="form-control" - data-qa-selector="wiki_message_textbox" + data-testid="wiki-message-textbox" :placeholder="$options.i18n.commitMessage.label" /> </gl-form-group> @@ -409,7 +409,6 @@ export default { category="primary" variant="confirm" type="submit" - data-qa-selector="wiki_submit_button" data-testid="wiki-submit-button" :disabled="disableSubmitButton" >{{ submitButtonText }}</gl-button diff --git a/app/assets/javascripts/pages/users/terms/index/index.js b/app/assets/javascripts/pages/users/terms/index/index.js index 29ddde6da94..3619bcff65c 100644 --- a/app/assets/javascripts/pages/users/terms/index/index.js +++ b/app/assets/javascripts/pages/users/terms/index/index.js @@ -1,4 +1,3 @@ import { initTermsApp } from '~/terms'; -import { waitForCSSLoaded } from '~/helpers/startup_css_helper'; -waitForCSSLoaded(initTermsApp); +initTermsApp(); diff --git a/app/assets/javascripts/performance_bar/components/add_request.vue b/app/assets/javascripts/performance_bar/components/add_request.vue index 6702c49030b..9a8ebedaf15 100644 --- a/app/assets/javascripts/performance_bar/components/add_request.vue +++ b/app/assets/javascripts/performance_bar/components/add_request.vue @@ -1,5 +1,5 @@ <script> -import { GlForm, GlFormInput, GlButton } from '@gitlab/ui'; +import { GlForm, GlFormInput, GlButton, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; export default { @@ -12,6 +12,9 @@ export default { GlButton, GlFormInput, }, + directives: { + GlTooltip: GlTooltipDirective, + }, data() { return { inputEnabled: false, @@ -37,7 +40,8 @@ export default { <div id="peek-view-add-request" class="view gl-display-flex"> <gl-form class="gl-display-flex gl-align-items-center" @submit.prevent> <gl-button - class="gl-text-blue-300! gl-mr-2" + v-gl-tooltip.viewport + class="gl-mr-2" category="tertiary" variant="link" icon="plus" @@ -52,7 +56,7 @@ export default { type="text" :placeholder="$options.i18n.inputLabel" :aria-label="$options.i18n.inputLabel" - class="gl-ml-2" + class="gl-ml-2 gl-px-3! gl-py-2!" @keyup.enter="addRequest" @keyup.esc="clearForm" /> diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index b53e2709f83..ab10283b3c4 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -1,5 +1,11 @@ <script> -import { GlButton, GlModal, GlModalDirective, GlCollapsibleListbox } from '@gitlab/ui'; +import { + GlButton, + GlTooltipDirective, + GlModal, + GlModalDirective, + GlCollapsibleListbox, +} from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { sortOrders, sortOrderOptions } from '../constants'; @@ -13,6 +19,7 @@ export default { GlCollapsibleListbox, }, directives: { + GlTooltip: GlTooltipDirective, 'gl-modal': GlModalDirective, }, props: { @@ -133,14 +140,17 @@ export default { <div v-if="currentRequest.details && metricDetails" :id="`peek-view-${metric}`" - class="gl-display-flex gl-align-items-center view" + class="gl-display-flex gl-align-items-baseline view" data-qa-selector="detailed_metric_content" > - <gl-button v-gl-modal="modalId" class="gl-mr-2" type="button" variant="link"> - <span - class="gl-text-blue-200 gl-font-weight-bold" - data-testid="performance-bar-details-label" - > + <gl-button + v-gl-tooltip.viewport + v-gl-modal="modalId" + class="gl-mr-2" + :title="header" + variant="link" + > + <span class="gl-font-sm gl-font-weight-semibold" data-testid="performance-bar-details-label"> {{ metricDetailsLabel }} </span> </gl-button> @@ -150,7 +160,7 @@ export default { <div v-for="(value, name) in metricDetailsSummary" :key="name" class="gl-pr-8"> <div v-if="value" data-testid="performance-bar-summary-item"> <div>{{ name }}</div> - <div class="gl-font-size-h1 gl-font-weight-bold">{{ value }}</div> + <div class="gl-font-size-h1 gl-font-weight-semibold">{{ value }}</div> </div> </div> </div> @@ -178,7 +188,7 @@ export default { v-for="(key, keyIndex) in keys" :key="key" class="text-break-word" - :class="{ 'mb-3 bold': keyIndex == 0 }" + :class="{ 'mb-3 gl-font-weight-semibold': keyIndex == 0 }" > {{ item[key] }} <gl-button @@ -214,7 +224,7 @@ export default { <div></div> </template> </gl-modal> - {{ actualTitle }} + <span class="gl-opacity-7">{{ actualTitle }}</span> <request-warning :html-id="htmlId" :warnings="warnings" /> </div> </template> 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 128c744f282..720c1e0d7f2 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -1,7 +1,5 @@ <script> -import { GlLink, GlPopover } from '@gitlab/ui'; -import SafeHtml from '~/vue_shared/directives/safe_html'; -import { glEmojiTag } from '~/emoji'; +import { GlLink, GlTooltipDirective } from '@gitlab/ui'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; @@ -11,14 +9,13 @@ import RequestSelector from './request_selector.vue'; export default { components: { - GlPopover, AddRequest, DetailedMetric, GlLink, RequestSelector, }, directives: { - SafeHtml, + GlTooltip: GlTooltipDirective, }, props: { store: { @@ -123,11 +120,8 @@ export default { hasHost() { return this.currentRequest && this.currentRequest.details && this.currentRequest.details.host; }, - birdEmoji() { - if (this.hasHost && this.currentRequest.details.host.canary) { - return glEmojiTag('baby_chick'); - } - return ''; + isCanary() { + return Boolean(this.currentRequest.details.host.canary); }, downloadPath() { const data = JSON.stringify(this.requests); @@ -165,7 +159,6 @@ export default { this.currentRequest = this.requestId; }, methods: { - glEmojiTag, changeCurrentRequest(newRequestId) { this.currentRequest = newRequestId; this.$emit('change-request', newRequestId); @@ -180,96 +173,117 @@ export default { return this.store.findRequest(requestId)?.method?.toUpperCase() === 'GET'; }, }, - safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, }; </script> <template> <div id="js-peek" :class="env"> <div v-if="currentRequest" - class="d-flex container-fluid container-limited justify-content-center gl-align-items-center" + class="gl-display-flex container-fluid gl-overflow-x-auto" data-qa-selector="performance_bar" > - <div id="peek-view-host" class="view"> - <span - v-if="hasHost" - class="current-host" - :class="{ canary: currentRequest.details.host.canary }" + <div class="gl-display-flex gl-flex-shrink-0 view-performance-container"> + <div v-if="hasHost" id="peek-view-host" class="gl-display-flex gl-gap-2 view"> + <span class="current-host" :class="{ canary: isCanary }"> + <gl-emoji + v-if="isCanary" + id="canary-emoji" + v-gl-tooltip.viewport="'Canary'" + data-name="baby_chick" + /> + <gl-emoji + id="host-emoji" + v-gl-tooltip.viewport="currentRequest.details.host.hostname" + data-name="computer" + /> + </span> + </div> + <detailed-metric + v-for="metric in $options.detailedMetrics" + :key="metric.metric" + :current-request="currentRequest" + :metric="metric.metric" + :title="metric.title" + :header="metric.header" + :keys="metric.keys" + /> + <div + v-if="currentRequest.details && currentRequest.details.tracing" + id="peek-view-trace" + class="view" > - <span id="canary-emoji" v-safe-html:[$options.safeHtmlConfig]="birdEmoji"></span> - <gl-popover placement="bottom" target="canary-emoji" content="Canary" /> - <span - id="host-emoji" - v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('computer')" - ></span> - <gl-popover - placement="bottom" - target="host-emoji" - :content="currentRequest.details.host.hostname" - /> - </span> + <gl-link + class="gl-text-decoration-underline" + :href="currentRequest.details.tracing.tracing_url" + >{{ s__('PerformanceBar|Trace') }}</gl-link + > + </div> + <div v-if="showFlamegraphButtons" id="peek-flamegraph" class="view"> + <gl-link + v-gl-tooltip.viewport + class="gl-font-sm" + :href="flamegraphPath('wall', currentRequestId)" + :title="s__('PerformanceBar|Wall flamegraph')" + >{{ s__('PerformanceBar|Wall') }}</gl-link + > + / + <gl-link + v-gl-tooltip.viewport + class="gl-font-sm" + :href="flamegraphPath('cpu', currentRequestId)" + :title="s__('PerformanceBar|CPU flamegraph')" + >{{ s__('PerformanceBar|CPU') }}</gl-link + > + / + <gl-link + v-gl-tooltip.viewport + class="gl-font-sm" + :href="flamegraphPath('object', currentRequestId)" + :title="s__('PerformanceBar|Object flamegraph')" + >{{ s__('PerformanceBar|Object') }}</gl-link + > + <span class="gl-opacity-7">{{ s__('PerformanceBar|flamegraph') }}</span> + </div> </div> - <detailed-metric - v-for="metric in $options.detailedMetrics" - :key="metric.metric" - :current-request="currentRequest" - :metric="metric.metric" - :title="metric.title" - :header="metric.header" - :keys="metric.keys" - /> - <div - v-if="currentRequest.details && currentRequest.details.tracing" - id="peek-view-trace" - class="view" - > - <gl-link class="gl-text-blue-200" :href="currentRequest.details.tracing.tracing_url">{{ - s__('PerformanceBar|Trace') - }}</gl-link> + <div class="gl-display-flex gl-flex-shrink-0 gl-ml-auto"> + <div class="gl-display-flex view-reports-container"> + <gl-link + v-if="currentRequest.details" + id="peek-download" + v-gl-tooltip.viewport + class="view gl-font-sm" + is-unsafe-link + :download="downloadName" + :href="downloadPath" + :title="s__('PerformanceBar|Download report')" + >{{ s__('PerformanceBar|Download') }}</gl-link + > + <gl-link + v-if="showMemoryReportButton" + id="peek-memory-report" + v-gl-tooltip.viewport + class="view gl-font-sm" + :href="memoryReportPath" + :title="s__('PerformanceBar|Download memory report')" + >{{ s__('PerformanceBar|Memory report') }}</gl-link + > + <gl-link + v-if="statsUrl" + v-gl-tooltip.viewport + class="view gl-font-sm" + :href="statsUrl" + :title="s__('PerformanceBar|Show stats')" + >{{ s__('PerformanceBar|Stats') }}</gl-link + > + </div> + <request-selector + v-if="currentRequest" + :current-request="currentRequest" + :requests="requests" + @change-current-request="changeCurrentRequest" + /> + <add-request v-on="$listeners" /> </div> - <div v-if="currentRequest.details" id="peek-download" class="view"> - <gl-link - class="gl-text-blue-200" - is-unsafe-link - :download="downloadName" - :href="downloadPath" - >{{ s__('PerformanceBar|Download') }}</gl-link - > - </div> - <div v-if="showMemoryReportButton" id="peek-memory-report" class="view"> - <gl-link class="gl-text-blue-200" :href="memoryReportPath">{{ - s__('PerformanceBar|Memory report') - }}</gl-link> - </div> - <div v-if="showFlamegraphButtons" id="peek-flamegraph" class="view"> - <span id="flamegraph-emoji" class="gl-text-white-200"> - <span v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('fire')"></span> - <span v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('bar_chart')"></span> - </span> - <gl-popover placement="bottom" target="flamegraph-emoji" content="Flamegraph" /> - <gl-link class="gl-text-blue-200" :href="flamegraphPath('wall', currentRequestId)">{{ - s__('PerformanceBar|wall') - }}</gl-link> - / - <gl-link class="gl-text-blue-200" :href="flamegraphPath('cpu', currentRequestId)">{{ - s__('PerformanceBar|cpu') - }}</gl-link> - / - <gl-link class="gl-text-blue-200" :href="flamegraphPath('object', currentRequestId)">{{ - s__('PerformanceBar|object') - }}</gl-link> - </div> - <gl-link v-if="statsUrl" class="gl-text-blue-200 view" :href="statsUrl">{{ - s__('PerformanceBar|Stats') - }}</gl-link> - <request-selector - v-if="currentRequest" - :current-request="currentRequest" - :requests="requests" - class="gl-ml-auto" - @change-current-request="changeCurrentRequest" - /> - <add-request v-on="$listeners" /> </div> </div> </template> diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue index f2177e102ec..2914b9762ac 100644 --- a/app/assets/javascripts/performance_bar/components/request_selector.vue +++ b/app/assets/javascripts/performance_bar/components/request_selector.vue @@ -1,5 +1,10 @@ <script> +import { GlFormSelect } from '@gitlab/ui'; + export default { + components: { + GlFormSelect, + }, props: { currentRequest: { type: Object, @@ -23,8 +28,8 @@ export default { }; </script> <template> - <div id="peek-request-selector" data-qa-selector="request_dropdown" class="view"> - <select v-model="currentRequestId"> + <div id="peek-request-selector" data-qa-selector="request_dropdown" class="view gl-mr-5"> + <gl-form-select v-model="currentRequestId" class="gl-px-3! gl-py-2!"> <option v-for="request in requests" :key="request.id" @@ -33,6 +38,6 @@ export default { > {{ request.displayName }} </option> - </select> + </gl-form-select> </div> </template> diff --git a/app/assets/javascripts/performance_bar/components/request_warning.vue b/app/assets/javascripts/performance_bar/components/request_warning.vue index 91e905d62e6..96c11ea9e4e 100644 --- a/app/assets/javascripts/performance_bar/components/request_warning.vue +++ b/app/assets/javascripts/performance_bar/components/request_warning.vue @@ -1,14 +1,9 @@ <script> -import { GlPopover } from '@gitlab/ui'; -import SafeHtml from '~/vue_shared/directives/safe_html'; -import { glEmojiTag } from '~/emoji'; +import { GlTooltipDirective } from '@gitlab/ui'; export default { - components: { - GlPopover, - }, directives: { - SafeHtml, + GlTooltip: GlTooltipDirective, }, props: { htmlId: { @@ -32,15 +27,17 @@ export default { return this.warnings.join('\n'); }, }, - methods: { - glEmojiTag, - }, safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, }; </script> <template> <span v-if="hasWarnings" class="gl-cursor-default"> - <span :id="htmlId" v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('warning')"></span> - <gl-popover placement="bottom" :target="htmlId" :content="warningMessage" /> + <gl-emoji + v-if="hasWarnings" + :id="htmlId" + v-gl-tooltip.viewport="warningMessage" + data-name="warning" + class="gl-ml-2" + /> </span> </template> diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue index ccecc914cf1..0feaf8db82b 100644 --- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue +++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue @@ -1,6 +1,6 @@ <script> -import { GlLoadingIcon, GlLink } from '@gitlab/ui'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { GlLoadingIcon } from '@gitlab/ui'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import { createAlert } from '~/alert'; import { getQueryHeaders, toggleQueryPollingByVisibility } from '~/ci/pipeline_details/graph/utils'; import getLatestPipelineStatusQuery from '../graphql/queries/get_latest_pipeline_status.query.graphql'; @@ -9,9 +9,8 @@ import { COMMIT_BOX_POLL_INTERVAL, PIPELINE_STATUS_FETCH_ERROR } from '../consta export default { PIPELINE_STATUS_FETCH_ERROR, components: { - CiIcon, + CiBadgeLink, GlLoadingIcon, - GlLink, }, inject: { fullPath: { @@ -64,8 +63,12 @@ export default { <template> <div class="gl-display-inline-block gl-vertical-align-middle gl-mr-2"> <gl-loading-icon v-if="loading" /> - <gl-link v-else :href="pipelineStatus.detailsPath"> - <ci-icon :status="pipelineStatus" :size="24" /> - </gl-link> + <ci-badge-link + v-else + :status="pipelineStatus" + :details-path="pipelineStatus.detailsPath" + size="md" + :show-text="false" + /> </div> </template> diff --git a/app/assets/javascripts/projects/components/shared/delete_button.vue b/app/assets/javascripts/projects/components/shared/delete_button.vue index c749034d2a8..b290b2b085f 100644 --- a/app/assets/javascripts/projects/components/shared/delete_button.vue +++ b/app/assets/javascripts/projects/components/shared/delete_button.vue @@ -87,7 +87,7 @@ export default { <gl-button category="primary" variant="danger" - data-qa-selector="delete_button" + data-testid="delete-button" @click="onButtonClick" >{{ $options.i18n.deleteProject }}</gl-button > diff --git a/app/assets/javascripts/projects/project_find_file.js b/app/assets/javascripts/projects/project_find_file.js index a8b884a68a0..711a8278e07 100644 --- a/app/assets/javascripts/projects/project_find_file.js +++ b/app/assets/javascripts/projects/project_find_file.js @@ -50,8 +50,6 @@ export default class ProjectFindFile { this.initEvent(); // focus text input box this.inputElement.focus(); - // load file list - this.load(this.options.url); } initEvent() { @@ -110,7 +108,14 @@ export default class ProjectFindFile { if (searchText) { matches = fuzzaldrinPlus.match(filePath, searchText); } - const blobItemUrl = joinPaths(this.options.blobUrlTemplate, escapeFileUrl(filePath)); + + let blobItemUrl = joinPaths(this.options.blobUrlTemplate, escapeFileUrl(filePath)); + + if (this.options.refType) { + const blobUrlObject = new URL(blobItemUrl, window.location.origin); + blobUrlObject.searchParams.append('ref_type', this.options.refType); + blobItemUrl = blobUrlObject.toString(); + } const html = ProjectFindFile.makeHtml(filePath, matches, blobItemUrl); results.push(this.element.find('.tree-table > tbody').append(html)); } diff --git a/app/assets/javascripts/projects/settings/api/access_dropdown_api.js b/app/assets/javascripts/projects/settings/api/access_dropdown_api.js index b886bf43b57..df99aac6b9e 100644 --- a/app/assets/javascripts/projects/settings/api/access_dropdown_api.js +++ b/app/assets/javascripts/projects/settings/api/access_dropdown_api.js @@ -1,9 +1,7 @@ -import Api from '~/api'; import axios from '~/lib/utils/axios_utils'; -import { ACCESS_LEVEL_DEVELOPER_INTEGER } from '~/access_level/constants'; -const GROUPS_PATH = '/-/autocomplete/project_groups.json'; const USERS_PATH = '/-/autocomplete/users.json'; +const GROUPS_PATH = '/-/autocomplete/project_groups.json'; const DEPLOY_KEYS_PATH = '/-/autocomplete/deploy_keys_with_owners.json'; const buildUrl = (urlRoot, url) => { @@ -28,14 +26,10 @@ export const getUsers = (query, states) => { }; export const getGroups = () => { - if (gon.current_project_id) { - return Api.projectGroups(gon.current_project_id, { - with_shared: true, - shared_min_access_level: ACCESS_LEVEL_DEVELOPER_INTEGER, - }); - } - return axios.get(buildUrl(gon.relative_url_root || '', GROUPS_PATH)).then(({ data }) => { - return data; + return axios.get(buildUrl(gon.relative_url_root || '', GROUPS_PATH), { + params: { + project_id: gon.current_project_id, + }, }); }; diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue index ca24e948f69..2dd7633e2c8 100644 --- a/app/assets/javascripts/projects/settings/components/access_dropdown.vue +++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue @@ -229,10 +229,10 @@ export default { Promise.all([ getDeployKeys(this.query), getUsers(this.query), - this.groups.length ? Promise.resolve(this.groups) : getGroups(), + this.groups.length ? Promise.resolve({ data: this.groups }) : getGroups(), ]) .then(([deployKeysResponse, usersResponse, groupsResponse]) => { - this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse); + this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse.data); this.setSelected({ initial }); }) .catch(() => diff --git a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue index fd5fabd7c8a..a426d6d7bb8 100644 --- a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue +++ b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue @@ -51,7 +51,7 @@ export default { :disabled="!hasSelectedNamespace" :phrase="confirmationPhrase" :button-text="confirmButtonText" - button-qa-selector="transfer_project_button" + button-testid="transfer-project-button" @confirm="$emit('confirm')" /> </div> diff --git a/app/assets/javascripts/ref/components/ambiguous_ref_modal.vue b/app/assets/javascripts/ref/components/ambiguous_ref_modal.vue new file mode 100644 index 00000000000..d17144669fe --- /dev/null +++ b/app/assets/javascripts/ref/components/ambiguous_ref_modal.vue @@ -0,0 +1,80 @@ +<!-- eslint-disable vue/multi-word-component-names --> +<script> +import { GlModal, GlButton, GlSprintf } from '@gitlab/ui'; +import { sprintf, s__ } from '~/locale'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { REF_TYPE_PARAM_NAME, TAG_REF_TYPE, BRANCH_REF_TYPE } from '../constants'; + +export default { + i18n: { + title: s__('AmbiguousRef|Which reference do you want to view?'), + description: sprintf( + s__('AmbiguousRef|There is a branch and a tag with the same name of %{ref}.'), + ), + secondaryDescription: s__('AmbiguousRef|Which reference would you like to view?'), + viewTagButton: s__('AmbiguousRef|View tag'), + viewBranchButton: s__('AmbiguousRef|View branch'), + }, + tagRefType: TAG_REF_TYPE, + branchRefType: BRANCH_REF_TYPE, + components: { + GlModal, + GlButton, + GlSprintf, + }, + + props: { + refName: { + type: String, + required: true, + }, + }, + mounted() { + this.$refs.ambiguousRefModal.show(); + }, + methods: { + navigate(refType) { + const url = new URL(window.location.href); + url.searchParams.set(REF_TYPE_PARAM_NAME, refType); + + visitUrl(url.toString()); + }, + }, +}; +</script> + +<template> + <gl-modal + ref="ambiguousRefModal" + modal-id="ambiguous-ref" + :title="$options.i18n.title" + @primary="navigate" + > + <p class="gl-mb-0"> + <gl-sprintf :message="$options.i18n.description"> + <template #ref + ><code>{{ refName }}</code></template + > + </gl-sprintf> + </p> + + <p> + {{ $options.i18n.secondaryDescription }} + </p> + + <template #modal-footer> + <gl-button + category="secondary" + variant="confirm" + @click="() => navigate($options.tagRefType)" + >{{ $options.i18n.viewTagButton }}</gl-button + > + <gl-button + category="secondary" + variant="confirm" + @click="() => navigate($options.branchRefType)" + >{{ $options.i18n.viewBranchButton }}</gl-button + > + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue index e5f5800c99c..ed9fd521e67 100644 --- a/app/assets/javascripts/ref/components/ref_selector.vue +++ b/app/assets/javascripts/ref/components/ref_selector.vue @@ -11,6 +11,10 @@ import { REF_TYPE_BRANCHES, REF_TYPE_TAGS, REF_TYPE_COMMITS, + TAG_REF_TYPE, + BRANCH_REF_TYPE, + TAG_REF_TYPE_ICON, + BRANCH_REF_TYPE_ICON, } from '../constants'; import createStore from '../stores'; import { formatListBoxItems, formatErrors } from '../format_refs'; @@ -159,6 +163,17 @@ export default { }) : this.i18n.noResults; }, + dropdownIcon() { + let icon; + + if (this.selectedRef.includes(`refs/${TAG_REF_TYPE}`)) { + icon = TAG_REF_TYPE_ICON; + } else if (this.selectedRef.includes(`refs/${BRANCH_REF_TYPE}`)) { + icon = BRANCH_REF_TYPE_ICON; + } + + return icon; + }, }, watch: { // Keep the Vuex store synchronized if the parent @@ -246,6 +261,7 @@ export default { :search-placeholder="i18n.searchPlaceholder" :toggle-class="extendedToggleButtonClass" :toggle-text="buttonText" + :icon="dropdownIcon" v-bind="$attrs" v-on="$listeners" @hidden="$emit('hide')" diff --git a/app/assets/javascripts/ref/constants.js b/app/assets/javascripts/ref/constants.js index 4b5b18cf6c1..5fd4660b8e3 100644 --- a/app/assets/javascripts/ref/constants.js +++ b/app/assets/javascripts/ref/constants.js @@ -7,6 +7,9 @@ export const REF_TYPE_COMMITS = 'REF_TYPE_COMMITS'; export const ALL_REF_TYPES = Object.freeze([REF_TYPE_BRANCHES, REF_TYPE_TAGS, REF_TYPE_COMMITS]); export const BRANCH_REF_TYPE = 'heads'; export const TAG_REF_TYPE = 'tags'; +export const TAG_REF_TYPE_ICON = 'tag'; +export const BRANCH_REF_TYPE_ICON = 'branch'; +export const REF_TYPE_PARAM_NAME = 'ref_type'; export const X_TOTAL_HEADER = 'x-total'; diff --git a/app/assets/javascripts/ref/init_ambiguous_ref_modal.js b/app/assets/javascripts/ref/init_ambiguous_ref_modal.js new file mode 100644 index 00000000000..00fb8f10401 --- /dev/null +++ b/app/assets/javascripts/ref/init_ambiguous_ref_modal.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; +import AmbiguousRefModal from './components/ambiguous_ref_modal.vue'; +import { REF_TYPE_PARAM_NAME, TAG_REF_TYPE, BRANCH_REF_TYPE } from './constants'; + +export default (el = document.querySelector('#js-ambiguous-ref-modal')) => { + const refType = getParameterByName(REF_TYPE_PARAM_NAME); + const isRefTypeSet = refType === TAG_REF_TYPE || refType === BRANCH_REF_TYPE; // if ref_type is already set in the URL, we don't want to display the modal + if (!el || isRefTypeSet || !parseBoolean(el.dataset.ambiguous)) return false; + + const { ref } = el.dataset; + + return new Vue({ + el, + render(createElement) { + return createElement(AmbiguousRefModal, { props: { refName: ref } }); + }, + }); +}; diff --git a/app/assets/javascripts/related_issues/components/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue index d36b29f69a5..a0e876b4c19 100644 --- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue +++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue @@ -219,7 +219,7 @@ export default { type="submit" size="small" class="gl-mr-2" - data-testid="add_issue_button" + data-testid="add-issue-button" > {{ __('Add') }} </gl-button> diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue index f92c81a7eb2..4811dfef3d0 100644 --- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue +++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue @@ -217,7 +217,7 @@ export default { :aria-label="inputPlaceholder" type="text" class="gl-w-full gl-border-none gl-outline-0" - data-testid="add_issue_field" + data-testid="add-issue-field" autocomplete="off" @input="onInput" @focus="onFocus" diff --git a/app/assets/javascripts/related_issues/components/related_issues_block.vue b/app/assets/javascripts/related_issues/components/related_issues_block.vue index 1044d25c1a3..f1b6b335509 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_block.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue @@ -278,20 +278,17 @@ export default { @saveReorder="$emit('saveReorder', $event)" /> </template> - <div v-if="!shouldShowTokenBody && !isFormVisible"> - <p class="gl-new-card-empty"> - {{ emptyStateMessage }} - <gl-link - v-if="hasHelpPath" - :href="helpPath" - target="_blank" - data-testid="help-link" - :aria-label="helpLinkText" - > - {{ __('Learn more.') }} - </gl-link> - </p> - </div> + <p v-if="!shouldShowTokenBody && !isFormVisible" class="gl-new-card-empty"> + {{ emptyStateMessage }} + <gl-link + v-if="hasHelpPath" + :href="helpPath" + data-testid="help-link" + :aria-label="helpLinkText" + > + {{ __('Learn more.') }} + </gl-link> + </p> </div> </gl-card> </div> diff --git a/app/assets/javascripts/related_issues/components/related_issues_list.vue b/app/assets/javascripts/related_issues/components/related_issues_list.vue index 8d26917f749..0e47184e24e 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_list.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue @@ -104,7 +104,7 @@ export default { {{ heading }} </h4> <div class="related-issues-token-body" :class="{ 'sortable-container': canReorder }"> - <div v-if="isFetching" class="gl-mb-2" data-testid="related_issues_loading_placeholder"> + <div v-if="isFetching" class="gl-mb-2" data-testid="related-issues-loading-placeholder"> <gl-loading-icon ref="loadingIcon" size="sm" @@ -146,7 +146,7 @@ export default { :locked-message="issue.lockedMessage" :work-item-type="issue.type" event-namespace="relatedIssue" - data-testid="related_issuable_content" + data-testid="related-issuable-content" class="gl-mx-n2" @relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)" /> diff --git a/app/assets/javascripts/releases/components/releases_empty_state.vue b/app/assets/javascripts/releases/components/releases_empty_state.vue index ae94bd6872e..2893a42c73b 100644 --- a/app/assets/javascripts/releases/components/releases_empty_state.vue +++ b/app/assets/javascripts/releases/components/releases_empty_state.vue @@ -21,10 +21,11 @@ export default { </script> <template> <gl-empty-state - class="gl-layout-w-limited" + class="gl-layout-w-limited gl-mx-auto" :title="$options.i18n.emptyStateTitle" :description="$options.i18n.emptyStateText" :svg-path="illustrationPath" + :svg-height="null" :primary-button-link="newReleasePath" :primary-button-text="$options.i18n.newRelease" :secondary-button-link="documentationPath" diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue index fe996a2a734..04f3d73235b 100644 --- a/app/assets/javascripts/releases/components/tag_field_new.vue +++ b/app/assets/javascripts/releases/components/tag_field_new.vue @@ -1,15 +1,18 @@ <script> -import { GlDropdown, GlFormGroup, GlPopover } from '@gitlab/ui'; +import { GlButton, GlTruncate, GlIcon, GlFormGroup, GlPopover } from '@gitlab/ui'; // eslint-disable-next-line no-restricted-imports import { mapState, mapActions, mapGetters } from 'vuex'; import { __, s__ } from '~/locale'; +import { ESC_KEY } from '~/lib/utils/keys'; import TagSearch from './tag_search.vue'; import TagCreate from './tag_create.vue'; export default { components: { - GlDropdown, + GlTruncate, + GlButton, + GlIcon, GlFormGroup, GlPopover, TagSearch, @@ -74,6 +77,13 @@ export default { }, hidePopover() { this.show = false; + // gl-button doesn't expose focus method, but we can find button element by id + document.getElementById(this.id)?.focus(); + }, + onPopoverKeyUp(e) { + if (e.code === ESC_KEY) { + this.hidePopover(); + } }, }, i18n: { @@ -97,15 +107,12 @@ export default { :invalid-feedback="tagFeedback" optional > - <gl-dropdown - :id="id" - :variant="buttonVariant" - :text="buttonText" - :toggle-class="['gl-text-gray-900!']" - category="secondary" - class="gl-w-30" - @show.prevent="showPopover" - /> + <gl-button :id="id" class="gl-w-30 gl-px-0!" @click="showPopover"> + <span class="gl-w-28 gl-display-flex gl-justify-content-space-between"> + <gl-truncate :text="buttonText" class="gl-max-w-26" /> + <gl-icon class="gl-button-icon gl-new-dropdown-chevron" name="chevron-down" /> + </span> + </gl-button> <gl-popover :show="show" :target="id" @@ -118,7 +125,7 @@ export default { @close-button-clicked="hidePopover" @hide.once="markInputAsDirty" > - <div class="gl-border-t-solid gl-border-t-1 gl-border-gray-200"> + <div class="gl-border-t-solid gl-border-t-1 gl-border-gray-200" @keyup="onPopoverKeyUp"> <tag-create v-if="isCreating" v-model="newTagName" diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js index 2e3cf3bf9b8..8bdfb057adc 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js @@ -1,6 +1,8 @@ +import { omit } from 'lodash'; import { getTag } from '~/rest_api'; import { createAlert } from '~/alert'; import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import AccessorUtilities from '~/lib/utils/accessor'; import { s__ } from '~/locale'; import createReleaseMutation from '~/releases/graphql/mutations/create_release.mutation.graphql'; import deleteReleaseMutation from '~/releases/graphql/mutations/delete_release.mutation.graphql'; @@ -15,16 +17,26 @@ import * as types from './mutation_types'; class GraphQLError extends Error {} -export const initializeRelease = ({ commit, dispatch, state }) => { +const updateDraft = (action) => (store, ...args) => { + action(store, ...args); + + if (!store.state.isExistingRelease) { + store.dispatch('saveDraftRelease'); + store.dispatch('saveDraftCreateFrom'); + } +}; + +export const initializeRelease = ({ dispatch, state }) => { if (state.isExistingRelease) { // When editing an existing release, // fetch the release object from the API return dispatch('fetchRelease'); } - // When creating a new release, initialize the - // store with an empty release object - commit(types.INITIALIZE_EMPTY_RELEASE); + // When creating a new release, try to load the + // store with a draft release object, otherwise + // initialize an empty one + dispatch('loadDraftRelease'); return Promise.resolve(); }; @@ -51,50 +63,58 @@ export const fetchRelease = async ({ commit, state }) => { } }; -export const updateReleaseTagName = ({ commit }, tagName) => - commit(types.UPDATE_RELEASE_TAG_NAME, tagName); +export const updateReleaseTagName = updateDraft(({ commit }, tagName) => + commit(types.UPDATE_RELEASE_TAG_NAME, tagName), +); -export const updateReleaseTagMessage = ({ commit }, tagMessage) => - commit(types.UPDATE_RELEASE_TAG_MESSAGE, tagMessage); +export const updateReleaseTagMessage = updateDraft(({ commit }, tagMessage) => + commit(types.UPDATE_RELEASE_TAG_MESSAGE, tagMessage), +); -export const updateCreateFrom = ({ commit }, createFrom) => - commit(types.UPDATE_CREATE_FROM, createFrom); +export const updateCreateFrom = updateDraft(({ commit }, createFrom) => + commit(types.UPDATE_CREATE_FROM, createFrom), +); export const updateShowCreateFrom = ({ commit }, showCreateFrom) => commit(types.UPDATE_SHOW_CREATE_FROM, showCreateFrom); -export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title); +export const updateReleaseTitle = updateDraft(({ commit }, title) => + commit(types.UPDATE_RELEASE_TITLE, title), +); -export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes); +export const updateReleaseNotes = updateDraft(({ commit }, notes) => + commit(types.UPDATE_RELEASE_NOTES, notes), +); -export const updateReleaseMilestones = ({ commit }, milestones) => - commit(types.UPDATE_RELEASE_MILESTONES, milestones); +export const updateReleaseMilestones = updateDraft(({ commit }, milestones) => + commit(types.UPDATE_RELEASE_MILESTONES, milestones), +); -export const updateReleaseGroupMilestones = ({ commit }, groupMilestones) => - commit(types.UPDATE_RELEASE_GROUP_MILESTONES, groupMilestones); +export const updateReleaseGroupMilestones = updateDraft(({ commit }, groupMilestones) => + commit(types.UPDATE_RELEASE_GROUP_MILESTONES, groupMilestones), +); -export const addEmptyAssetLink = ({ commit }) => { - commit(types.ADD_EMPTY_ASSET_LINK); -}; +export const addEmptyAssetLink = updateDraft(({ commit }) => commit(types.ADD_EMPTY_ASSET_LINK)); -export const updateAssetLinkUrl = ({ commit }, { linkIdToUpdate, newUrl }) => { - commit(types.UPDATE_ASSET_LINK_URL, { linkIdToUpdate, newUrl }); -}; +export const updateAssetLinkUrl = updateDraft(({ commit }, { linkIdToUpdate, newUrl }) => + commit(types.UPDATE_ASSET_LINK_URL, { linkIdToUpdate, newUrl }), +); -export const updateAssetLinkName = ({ commit }, { linkIdToUpdate, newName }) => { - commit(types.UPDATE_ASSET_LINK_NAME, { linkIdToUpdate, newName }); -}; +export const updateAssetLinkName = updateDraft(({ commit }, { linkIdToUpdate, newName }) => + commit(types.UPDATE_ASSET_LINK_NAME, { linkIdToUpdate, newName }), +); -export const updateAssetLinkType = ({ commit }, { linkIdToUpdate, newType }) => { - commit(types.UPDATE_ASSET_LINK_TYPE, { linkIdToUpdate, newType }); -}; +export const updateAssetLinkType = updateDraft(({ commit }, { linkIdToUpdate, newType }) => + commit(types.UPDATE_ASSET_LINK_TYPE, { linkIdToUpdate, newType }), +); -export const removeAssetLink = ({ commit }, linkIdToRemove) => { - commit(types.REMOVE_ASSET_LINK, linkIdToRemove); -}; +export const removeAssetLink = updateDraft(({ commit }, linkIdToRemove) => + commit(types.REMOVE_ASSET_LINK, linkIdToRemove), +); -export const receiveSaveReleaseSuccess = ({ commit }, urlToRedirectTo) => { +export const receiveSaveReleaseSuccess = ({ commit, dispatch }, urlToRedirectTo) => { commit(types.RECEIVE_SAVE_RELEASE_SUCCESS); + dispatch('clearDraftRelease'); redirectTo(urlToRedirectTo); // eslint-disable-line import/no-deprecated }; @@ -245,9 +265,9 @@ export const updateIncludeTagNotes = ({ commit }, includeTagNotes) => { commit(types.UPDATE_INCLUDE_TAG_NOTES, includeTagNotes); }; -export const updateReleasedAt = ({ commit }, releasedAt) => { - commit(types.UPDATE_RELEASED_AT, releasedAt); -}; +export const updateReleasedAt = updateDraft(({ commit }, releasedAt) => + commit(types.UPDATE_RELEASED_AT, releasedAt), +); export const deleteRelease = ({ commit, getters, dispatch, state }) => { commit(types.REQUEST_SAVE_RELEASE); @@ -274,3 +294,56 @@ export const setCreating = ({ commit }) => commit(types.SET_CREATING); export const setExistingTag = ({ commit }) => commit(types.SET_EXISTING_TAG); export const setNewTag = ({ commit }) => commit(types.SET_NEW_TAG); + +export const saveDraftRelease = ({ getters, state }) => { + try { + window.localStorage.setItem( + getters.localStorageKey, + JSON.stringify(getters.releasedAtChanged ? state.release : omit(state.release, 'releasedAt')), + ); + } catch { + return Promise.resolve(); + } + return Promise.resolve(); +}; + +export const saveDraftCreateFrom = ({ getters, state }) => { + try { + window.localStorage.setItem( + getters.localStorageCreateFromKey, + JSON.stringify(state.createFrom), + ); + } catch { + return Promise.resolve(); + } + return Promise.resolve(); +}; + +export const clearDraftRelease = ({ getters }) => { + if (AccessorUtilities.canUseLocalStorage()) { + window.localStorage.removeItem(getters.localStorageKey); + window.localStorage.removeItem(getters.localStorageCreateFromKey); + } +}; + +export const loadDraftRelease = ({ commit, getters, state }) => { + try { + const release = window.localStorage.getItem(getters.localStorageKey); + const createFrom = window.localStorage.getItem(getters.localStorageCreateFromKey); + + if (release) { + const parsedRelease = JSON.parse(release); + commit(types.INITIALIZE_RELEASE, { + ...parsedRelease, + releasedAt: parsedRelease.releasedAt + ? new Date(parsedRelease.releasedAt) + : state.originalReleasedAt, + }); + commit(types.UPDATE_CREATE_FROM, JSON.parse(createFrom)); + } else { + commit(types.INITIALIZE_EMPTY_RELEASE); + } + } catch { + commit(types.INITIALIZE_EMPTY_RELEASE); + } +}; diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js index edf6c81c9e9..0b37c2b81d1 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js @@ -190,3 +190,7 @@ export const isCreating = ({ step }) => step === CREATE; export const isExistingTag = ({ tagStep }) => tagStep === EXISTING_TAG; export const isNewTag = ({ tagStep }) => tagStep === NEW_TAG; + +export const localStorageKey = ({ projectPath }) => `${projectPath}/release/new`; +export const localStorageCreateFromKey = ({ projectPath }) => + `${projectPath}/release/new/createFrom`; diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js index fc450970cde..8a0eeaa4338 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js @@ -1,4 +1,5 @@ export const INITIALIZE_EMPTY_RELEASE = 'INITIALIZE_EMPTY_RELEASE'; +export const INITIALIZE_RELEASE = 'INITIALIZE_RELEASE'; export const REQUEST_RELEASE = 'REQUEST_RELEASE'; export const RECEIVE_RELEASE_SUCCESS = 'RECEIVE_RELEASE_SUCCESS'; diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js index 7ff18245a80..3a68cdbb89a 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js @@ -22,6 +22,9 @@ export default { }, }; }, + [types.INITIALIZE_RELEASE](state, release) { + state.release = release; + }, [types.REQUEST_RELEASE](state) { state.isFetchingRelease = true; diff --git a/app/assets/javascripts/repository/components/blob_controls.vue b/app/assets/javascripts/repository/components/blob_controls.vue index 460db0fe2ae..4730c9575da 100644 --- a/app/assets/javascripts/repository/components/blob_controls.vue +++ b/app/assets/javascripts/repository/components/blob_controls.vue @@ -30,6 +30,7 @@ export default { projectPath: this.projectPath, filePath: this.filePath, ref: this.ref, + refType: this.refType?.toUpperCase(), }; }, skip() { @@ -45,6 +46,11 @@ export default { type: String, required: true, }, + refType: { + type: String, + required: false, + default: null, + }, }, data() { return { diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index b347f97a5ae..e3cd2d2e842 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -184,7 +184,7 @@ export default { this.currentPath ? encodeURIComponent(this.currentPath) : '', ), extraAttrs: { - 'data-qa-selector': 'new_file_menu_item', + 'data-testid': 'new-file-menu-item', }, }, { @@ -284,7 +284,6 @@ export default { :toggle-text="__('Add to tree')" toggle-class="add-to-tree gl-ml-2" data-testid="add-to-tree" - data-qa-selector="add_to_tree_dropdown" text-sr-only icon="plus" :items="dropdownItems" diff --git a/app/assets/javascripts/repository/components/commit_info.vue b/app/assets/javascripts/repository/components/commit_info.vue new file mode 100644 index 00000000000..b6e3cdbb7a3 --- /dev/null +++ b/app/assets/javascripts/repository/components/commit_info.vue @@ -0,0 +1,116 @@ +<script> +import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import defaultAvatarUrl from 'images/no_avatar.png'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; +import getRefMixin from '../mixins/get_ref'; + +export default { + components: { + UserAvatarLink, + TimeagoTooltip, + GlButton, + GlLink, + UserAvatarImage, + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml, + }, + mixins: [getRefMixin], + props: { + commit: { + type: Object, + required: true, + }, + }, + data() { + return { showDescription: false }; + }, + computed: { + commitDescription() { + // Strip the newline at the beginning + return this.commit?.descriptionHtml?.replace(/^
/, ''); + }, + }, + methods: { + toggleShowDescription() { + this.showDescription = !this.showDescription; + }, + }, + defaultAvatarUrl, + safeHtmlConfig: { + ADD_TAGS: ['gl-emoji'], + }, + i18n: { + toggleCommitDescription: __('Toggle commit description'), + authored: __('authored'), + }, +}; +</script> + +<template> + <div class="well-segment commit gl-min-h-8 gl-p-2 gl-w-full gl-display-flex"> + <user-avatar-link + v-if="commit.author" + :link-href="commit.author.webPath" + :img-src="commit.author.avatarUrl" + :img-size="32" + class="gl-my-2 gl-mr-4" + /> + <user-avatar-image + v-else + class="gl-my-2 gl-mr-4" + :img-src="commit.authorGravatar || $options.defaultAvatarUrl" + :size="32" + /> + <div + class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-center gl-flex-grow-1 gl-min-w-0" + > + <div class="commit-content" data-qa-selector="commit_content"> + <gl-link + v-safe-html:[$options.safeHtmlConfig]="commit.titleHtml" + :href="commit.webPath" + :class="{ 'gl-font-style-italic': !commit.message }" + class="commit-row-message item-title" + /> + <gl-button + v-if="commit.descriptionHtml" + v-gl-tooltip + :class="{ open: showDescription }" + :title="$options.i18n.toggleCommitDescription" + :aria-label="$options.i18n.toggleCommitDescription" + :selected="showDescription" + class="text-expander gl-vertical-align-bottom!" + icon="ellipsis_h" + @click="toggleShowDescription" + /> + <div class="committer"> + <gl-link + v-if="commit.author" + :href="commit.author.webPath" + class="commit-author-link js-user-link" + > + {{ commit.author.name }}</gl-link + > + <template v-else> + {{ commit.authorName }} + </template> + {{ $options.i18n.authored }} + <timeago-tooltip :time="commit.authoredDate" tooltip-placement="bottom" /> + </div> + <pre + v-if="commitDescription" + v-safe-html:[$options.safeHtmlConfig]="commitDescription" + :class="{ 'gl-display-block!': showDescription }" + class="commit-row-description gl-mb-3 gl-white-space-pre-line" + ></pre> + </div> + <div class="gl-flex-grow-1"></div> + <slot></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/repository/components/fork_info.vue b/app/assets/javascripts/repository/components/fork_info.vue index 42108e8dfba..c0adbc6f38c 100644 --- a/app/assets/javascripts/repository/components/fork_info.vue +++ b/app/assets/javascripts/repository/components/fork_info.vue @@ -291,7 +291,7 @@ export default { > <div v-if="sourceName"> {{ $options.i18n.forkedFrom }} - <gl-link data-qa-selector="forked_from_link" :href="sourcePath">{{ sourceName }}</gl-link> + <gl-link data-testid="forked-from-link" :href="sourcePath">{{ sourceName }}</gl-link> <gl-skeleton-loader v-if="isLoading" :lines="1" /> <div v-else class="gl-text-secondary" data-testid="divergence-message"> <gl-sprintf :message="forkDivergenceMessage"> diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 12edeeb0d2f..05d4d9e1f81 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -1,32 +1,26 @@ <script> -import { GlTooltipDirective, GlLink, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui'; +import { GlTooltipDirective, 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'; import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; import SignatureBadge from '~/commit/components/signature_badge.vue'; import getRefMixin from '../mixins/get_ref'; import projectPathQuery from '../queries/project_path.query.graphql'; import eventHub from '../event_hub'; import { FORK_UPDATED_EVENT } from '../constants'; +import CommitInfo from './commit_info.vue'; export default { components: { - UserAvatarLink, - TimeagoTooltip, + CommitInfo, ClipboardButton, - GlButton, - GlButtonGroup, - GlLink, - GlLoadingIcon, - UserAvatarImage, SignatureBadge, CiBadgeLink, + GlButtonGroup, + GlButton, + GlLoadingIcon, }, directives: { GlTooltip: GlTooltipDirective, @@ -80,13 +74,12 @@ export default { return { projectPath: '', commit: null, - showDescription: false, }; }, computed: { statusTitle() { return sprintf(s__('PipelineStatusTooltip|Pipeline: %{ciStatus}'), { - ciStatus: this.commit.pipeline.detailedStatus.text, + ciStatus: this.commit?.pipeline?.detailedStatus?.text, }); }, isLoading() { @@ -95,10 +88,6 @@ export default { showCommitId() { return this.commit?.sha?.substr(0, 8); }, - commitDescription() { - // Strip the newline at the beginning - return this.commit?.descriptionHtml?.replace(/^
/, ''); - }, }, watch: { currentPath() { @@ -112,112 +101,39 @@ export default { eventHub.$off(FORK_UPDATED_EVENT, this.refetchLastCommit); }, methods: { - toggleShowDescription() { - this.showDescription = !this.showDescription; - }, refetchLastCommit() { this.$apollo.queries.commit.refetch(); }, }, - defaultAvatarUrl, - safeHtmlConfig: { - ADD_TAGS: ['gl-emoji'], - }, }; </script> <template> - <div class="well-segment commit gl-p-5 gl-w-full gl-display-flex"> - <gl-loading-icon v-if="isLoading" size="lg" color="dark" class="m-auto" /> - <template v-else-if="commit"> - <user-avatar-link - v-if="commit.author" - :link-href="commit.author.webPath" - :img-src="commit.author.avatarUrl" - :img-size="32" - class="gl-my-2 gl-mr-4" - /> - <user-avatar-image - v-else - class="gl-my-2 gl-mr-4" - :img-src="commit.authorGravatar || $options.defaultAvatarUrl" - :size="32" - /> - <div - class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-center gl-flex-grow-1 gl-min-w-0" - > - <div class="commit-content" data-qa-selector="commit_content"> - <gl-link - v-safe-html:[$options.safeHtmlConfig]="commit.titleHtml" - :href="commit.webPath" - :class="{ 'font-italic': !commit.message }" - class="commit-row-message item-title" - /> - <gl-button - v-if="commit.descriptionHtml" - v-gl-tooltip - :class="{ open: showDescription }" - :title="__('Toggle commit description')" - :aria-label="__('Toggle commit description')" - :selected="showDescription" - class="text-expander gl-vertical-align-bottom!" - icon="ellipsis_h" - @click="toggleShowDescription" - /> - <div class="committer"> - <gl-link - v-if="commit.author" - :href="commit.author.webPath" - class="commit-author-link js-user-link" - > - {{ commit.author.name }}</gl-link - > - <template v-else> - {{ commit.authorName }} - </template> - {{ s__('LastCommit|authored') }} - <timeago-tooltip :time="commit.authoredDate" tooltip-placement="bottom" /> - </div> - <pre - v-if="commitDescription" - v-safe-html:[$options.safeHtmlConfig]="commitDescription" - :class="{ 'd-block': showDescription }" - class="commit-row-description gl-mb-3 gl-white-space-pre-line" - ></pre> - </div> - <div class="gl-flex-grow-1"></div> - <div - class="commit-actions gl-display-flex gl-flex-align gl-align-items-center gl-flex-direction-row" - > - <signature-badge v-if="commit.signature" :signature="commit.signature" /> - <div v-if="commit.pipeline" class="ci-status-link"> - <ci-badge-link - :status="commit.pipeline.detailedStatus" - :details-path="commit.pipeline.detailedStatus.detailsPath" - :aria-label="statusTitle" - size="lg" - :show-text="false" - class="js-commit-pipeline" - /> - </div> - <gl-button-group class="gl-ml-4 js-commit-sha-group"> - <gl-button label class="gl-font-monospace" data-testid="last-commit-id-label">{{ - showCommitId - }}</gl-button> - <clipboard-button - :text="commit.sha" - :title="__('Copy commit SHA')" - class="input-group-text" - /> - </gl-button-group> - </div> + <gl-loading-icon v-if="isLoading" size="lg" color="dark" class="m-auto" /> + <commit-info v-else-if="commit" :commit="commit"> + <div + class="commit-actions gl-display-flex gl-flex-align gl-align-items-center gl-flex-direction-row" + > + <signature-badge v-if="commit.signature" :signature="commit.signature" /> + <div v-if="commit.pipeline" class="ci-status-link"> + <ci-badge-link + :status="commit.pipeline.detailedStatus" + :details-path="commit.pipeline.detailedStatus.detailsPath" + :aria-label="statusTitle" + :show-text="false" + class="js-commit-pipeline" + /> </div> - </template> - </div> + <gl-button-group class="gl-ml-4 js-commit-sha-group"> + <gl-button label class="gl-font-monospace" data-testid="last-commit-id-label">{{ + showCommitId + }}</gl-button> + <clipboard-button + :text="commit.sha" + :title="__('Copy commit SHA')" + class="input-group-text" + /> + </gl-button-group> + </div> + </commit-info> </template> - -<style scoped> -.commit { - min-height: 4.75rem; -} -</style> diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue index bdcacd80b30..be446260f82 100644 --- a/app/assets/javascripts/repository/components/preview/index.vue +++ b/app/assets/javascripts/repository/components/preview/index.vue @@ -67,7 +67,7 @@ export default { </gl-link> </div> </div> - <div class="blob-viewer" data-qa-selector="blob_viewer_content" itemprop="about"> + <div class="blob-viewer" data-testid="blob-viewer-content" itemprop="about"> <gl-loading-icon v-if="isLoading" size="lg" color="dark" class="my-4 mx-auto" /> <div v-else-if="readme" diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 557e9cd168f..3da7daa3eec 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -118,7 +118,7 @@ export default { class="table tree-table" :class="{ 'gl-table-layout-fixed': !showParentRow }" aria-live="polite" - data-qa-selector="file_tree_table" + data-testid="file-tree-table" > <table-header v-once /> <tbody> diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index a76d822317a..526757e6147 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -219,7 +219,7 @@ export default { 'is-submodule': isSubmodule, }" class="tree-item-link str-truncated" - data-qa-selector="file_name_link" + data-testid="file-name-link" > <file-icon :file-name="fullPath" diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 9753173ac30..afe3f7b1983 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -122,7 +122,7 @@ export default function setupVueRepositoryList() { return h(LastCommit, { props: { currentPath: this.$route.params.path, - refType: this.$route.query.ref_type, + refType: this.$route.meta.refType || this.$route.query.ref_type, }, }); }, @@ -137,6 +137,7 @@ export default function setupVueRepositoryList() { return h(BlobControls, { props: { projectPath, + refType: this.$route.meta.refType || this.$route.query.ref_type, }, }); }, @@ -231,19 +232,21 @@ export default function setupVueRepositoryList() { const treeHistoryLinkEl = document.getElementById('js-tree-history-link'); const { historyLink } = treeHistoryLinkEl.dataset; - // eslint-disable-next-line no-new new Vue({ el: treeHistoryLinkEl, router, render(h) { + const url = new URL(window.location.href); + url.pathname = `${historyLink}/${ + this.$route.params.path ? escapeFileUrl(this.$route.params.path) : '' + }`; + url.searchParams.set('ref_type', this.$route.meta.refType || this.$route.query.ref_type); return h( GlButton, { attrs: { - href: `${historyLink}/${ - this.$route.params.path ? escapeFileUrl(this.$route.params.path) : '' - }`, + href: url.href, // Ideally passing this class to `props` should work // But it doesn't work here. :( class: 'btn btn-default btn-md gl-button', @@ -256,7 +259,7 @@ export default function setupVueRepositoryList() { initWebIdeLink({ el: document.getElementById('js-tree-web-ide-link'), router }); - const directoryDownloadLinks = document.getElementById('js-directory-downloads'); + const directoryDownloadLinks = document.querySelector('.js-directory-downloads'); if (directoryDownloadLinks) { // eslint-disable-next-line no-new diff --git a/app/assets/javascripts/repository/queries/blob_controls.query.graphql b/app/assets/javascripts/repository/queries/blob_controls.query.graphql index fc1cf5f254b..0c284dcc8e6 100644 --- a/app/assets/javascripts/repository/queries/blob_controls.query.graphql +++ b/app/assets/javascripts/repository/queries/blob_controls.query.graphql @@ -1,8 +1,8 @@ -query getBlobControls($projectPath: ID!, $filePath: String!, $ref: String!) { +query getBlobControls($projectPath: ID!, $filePath: String!, $ref: String!, $refType: RefType) { project(fullPath: $projectPath) { id repository { - blobs(paths: [$filePath], ref: $ref) { + blobs(paths: [$filePath], ref: $ref, refType: $refType) { nodes { id findFilePath diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js index 5f73912ed2b..31bafab742d 100644 --- a/app/assets/javascripts/repository/router.js +++ b/app/assets/javascripts/repository/router.js @@ -63,6 +63,9 @@ export default function createRouter(base, baseRef) { props: { refType: 'HEADS', }, + meta: { + refType: 'HEADS', + }, }, ], }); diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue index 532a66affd8..2ff138cabe5 100644 --- a/app/assets/javascripts/search/sidebar/components/app.vue +++ b/app/assets/javascripts/search/sidebar/components/app.vue @@ -15,6 +15,7 @@ import { SCOPE_PROJECTS, SCOPE_NOTES, SCOPE_COMMITS, + SCOPE_MILESTONES, SEARCH_TYPE_ADVANCED, } from '../constants'; import IssuesFilters from './issues_filters.vue'; @@ -23,6 +24,7 @@ import BlobsFilters from './blobs_filters.vue'; import ProjectsFilters from './projects_filters.vue'; import NotesFilters from './notes_filters.vue'; import CommitsFilters from './commits_filters.vue'; +import MilestonesFilters from './milestones_filters.vue'; export default { name: 'GlobalSearchSidebar', @@ -38,6 +40,7 @@ export default { DomElementListener, SmallScreenDrawerNavigation, CommitsFilters, + MilestonesFilters, }, mixins: [glFeatureFlagsMixin()], computed: { @@ -57,18 +60,20 @@ export default { return this.currentScope === SCOPE_PROJECTS; }, showNotesFilters() { - return ( - this.currentScope === SCOPE_NOTES && - this.searchType === SEARCH_TYPE_ADVANCED && - this.glFeatures.searchNotesHideArchivedProjects - ); + // for now, the feature flag is placed here. Since we have only one filter in notes scope + return this.currentScope === SCOPE_NOTES && this.glFeatures.searchNotesHideArchivedProjects; }, showCommitsFilters() { // for now, the feature flag is placed here. Since we have only one filter in commits scope return ( - this.currentScope === SCOPE_COMMITS && - this.searchType === SEARCH_TYPE_ADVANCED && - this.glFeatures.searchCommitsHideArchivedProjects + this.currentScope === SCOPE_COMMITS && this.glFeatures.searchCommitsHideArchivedProjects + ); + }, + showMilestonesFilters() { + // for now, the feature flag is placed here. Since we have only one filter in milestones scope + return ( + this.currentScope === SCOPE_MILESTONES && + this.glFeatures.searchMilestonesHideArchivedProjects ); }, showScopeNavigation() { @@ -97,6 +102,7 @@ export default { <projects-filters v-if="showProjectsFilters" /> <notes-filters v-if="showNotesFilters" /> <commits-filters v-if="showCommitsFilters" /> + <milestones-filters v-if="showMilestonesFilters" /> </sidebar-portal> </section> @@ -112,6 +118,7 @@ export default { <projects-filters v-if="showProjectsFilters" /> <notes-filters v-if="showNotesFilters" /> <commits-filters v-if="showCommitsFilters" /> + <milestones-filters v-if="showMilestonesFilters" /> </div> <small-screen-drawer-navigation class="gl-lg-display-none"> <scope-legacy-navigation /> @@ -121,6 +128,7 @@ export default { <projects-filters v-if="showProjectsFilters" /> <notes-filters v-if="showNotesFilters" /> <commits-filters v-if="showCommitsFilters" /> + <milestones-filters v-if="showMilestonesFilters" /> </small-screen-drawer-navigation> </section> </template> diff --git a/app/assets/javascripts/search/sidebar/components/archived_filter/data.js b/app/assets/javascripts/search/sidebar/components/archived_filter/data.js index 5cddf5e744f..ed90e2aaded 100644 --- a/app/assets/javascripts/search/sidebar/components/archived_filter/data.js +++ b/app/assets/javascripts/search/sidebar/components/archived_filter/data.js @@ -5,14 +5,7 @@ const checkboxLabel = s__('GlobalSearch|Include archived'); export const TRACKING_NAMESPACE = 'search:archived:select'; export const TRACKING_LABEL_CHECKBOX = 'checkbox'; -const scopes = { - PROJECTS: 'projects', - ISSUES: 'issues', - MERGE_REQUESTS: 'merge_requests', - NOTES: 'notes', - BLOBS: 'blobs', - COMMITS: 'commits', -}; +const scopes = ['projects', 'issues', 'merge_requests', 'notes', 'blobs', 'commits', 'milestones']; const filterParam = 'include_archived'; diff --git a/app/assets/javascripts/search/sidebar/components/archived_filter/index.vue b/app/assets/javascripts/search/sidebar/components/archived_filter/index.vue index c31c46f2e6a..b0e84beabc4 100644 --- a/app/assets/javascripts/search/sidebar/components/archived_filter/index.vue +++ b/app/assets/javascripts/search/sidebar/components/archived_filter/index.vue @@ -1,7 +1,8 @@ <script> -import { GlFormCheckboxGroup, GlFormCheckbox } from '@gitlab/ui'; +import { GlFormCheckboxGroup, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui'; // eslint-disable-next-line no-restricted-imports import { mapState, mapActions } from 'vuex'; +import { s__ } from '~/locale'; import Tracking from '~/tracking'; import { parseBoolean } from '~/lib/utils/common_utils'; @@ -13,6 +14,12 @@ export default { GlFormCheckboxGroup, GlFormCheckbox, }, + directives: { + GlTooltip: GlTooltipDirective, + }, + i18n: { + tooltip: s__('GlobalSearch|Include search results from archived projects'), + }, computed: { ...mapState(['urlQuery', 'useSidebarNavigation']), selectedFilter: { @@ -20,9 +27,9 @@ export default { return [parseBoolean(this.urlQuery?.include_archived)]; }, set(value) { - const newValue = value?.pop() ?? false; - this.setQuery({ key: archivedFilterData.filterParam, value: newValue?.toString() }); - this.trackSelectCheckbox(newValue); + const includeArchived = [...value].pop() ?? false; + this.setQuery({ key: archivedFilterData.filterParam, value: includeArchived?.toString() }); + this.trackSelectCheckbox(includeArchived); }, }, }, @@ -49,7 +56,7 @@ export default { :class="$options.LABEL_DEFAULT_CLASSES" :value="true" > - <span data-testid="label"> + <span v-gl-tooltip="$options.i18n.tooltip" data-testid="label"> {{ $options.archivedFilterData.checkboxLabel }} </span> </gl-form-checkbox> diff --git a/app/assets/javascripts/search/sidebar/components/issues_filters.vue b/app/assets/javascripts/search/sidebar/components/issues_filters.vue index dbd52978163..4a2d3df6921 100644 --- a/app/assets/javascripts/search/sidebar/components/issues_filters.vue +++ b/app/assets/javascripts/search/sidebar/components/issues_filters.vue @@ -42,9 +42,8 @@ export default { }, showArchivedFilter() { return ( - Object.values(archivedFilterData.scopes).includes(this.currentScope) && - this.glFeatures.searchIssuesHideArchivedProjects && - this.searchType === SEARCH_TYPE_ADVANCED + archivedFilterData.scopes.includes(this.currentScope) && + this.glFeatures.searchIssuesHideArchivedProjects ); }, showDivider() { diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/index.vue b/app/assets/javascripts/search/sidebar/components/label_filter/index.vue index a6af789baad..ebd0406bcec 100644 --- a/app/assets/javascripts/search/sidebar/components/label_filter/index.vue +++ b/app/assets/javascripts/search/sidebar/components/label_filter/index.vue @@ -225,7 +225,7 @@ export default { v-if="isFocused" v-outside="closeDropdown" data-testid="header-search-dropdown-menu" - class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0 gl-mt-3 gl-z-index-1" + class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0 gl-mt-3 gl-z-index-2" :class="{ 'gl-max-w-none!': useSidebarNavigation, 'gl-min-w-full!': useSidebarNavigation, diff --git a/app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue b/app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue index 2845eb2049b..6e476ef7935 100644 --- a/app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue +++ b/app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue @@ -2,7 +2,7 @@ // eslint-disable-next-line no-restricted-imports import { mapGetters, mapState } from 'vuex'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { HR_DEFAULT_CLASSES, SEARCH_TYPE_ADVANCED } from '../constants'; +import { HR_DEFAULT_CLASSES } from '../constants'; import { statusFilterData } from './status_filter/data'; import StatusFilter from './status_filter/index.vue'; import FiltersTemplate from './filters_template.vue'; @@ -22,9 +22,8 @@ export default { ...mapState(['useSidebarNavigation', 'searchType']), showArchivedFilter() { return ( - Object.values(archivedFilterData.scopes).includes(this.currentScope) && - this.glFeatures.searchMergeRequestsHideArchivedProjects && - this.searchType === SEARCH_TYPE_ADVANCED + archivedFilterData.scopes.includes(this.currentScope) && + this.glFeatures.searchMergeRequestsHideArchivedProjects ); }, showStatusFilter() { diff --git a/app/assets/javascripts/search/sidebar/components/milestones_filters.vue b/app/assets/javascripts/search/sidebar/components/milestones_filters.vue new file mode 100644 index 00000000000..098e2980c3f --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/milestones_filters.vue @@ -0,0 +1,18 @@ +<script> +import ArchivedFilter from './archived_filter/index.vue'; +import FiltersTemplate from './filters_template.vue'; + +export default { + name: 'MilestonesFilters', + components: { + ArchivedFilter, + FiltersTemplate, + }, +}; +</script> + +<template> + <filters-template> + <archived-filter class="gl-mb-5" /> + </filters-template> +</template> diff --git a/app/assets/javascripts/search/sidebar/constants/index.js b/app/assets/javascripts/search/sidebar/constants/index.js index 19df875c292..b5446ecbb42 100644 --- a/app/assets/javascripts/search/sidebar/constants/index.js +++ b/app/assets/javascripts/search/sidebar/constants/index.js @@ -4,6 +4,7 @@ export const SCOPE_BLOB = 'blobs'; export const SCOPE_PROJECTS = 'projects'; export const SCOPE_NOTES = 'notes'; export const SCOPE_COMMITS = 'commits'; +export const SCOPE_MILESTONES = 'milestones'; export const LABEL_DEFAULT_CLASSES = [ 'gl-display-flex', 'gl-flex-direction-row', diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js index f3b4a09b45b..ad47cd975f8 100644 --- a/app/assets/javascripts/search/store/constants.js +++ b/app/assets/javascripts/search/store/constants.js @@ -35,3 +35,7 @@ export const ICON_MAP = { wiki_blobs: 'book', snippet_titles: 'snippet', }; + +export const ZOEKT_SEARCH_TYPE = 'zoekt'; +export const ADVANCED_SEARCH_TYPE = 'advanced'; +export const BASIC_SEARCH_TYPE = 'basic'; diff --git a/app/assets/javascripts/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue index ee66bdb2632..49e66492519 100644 --- a/app/assets/javascripts/search/topbar/components/app.vue +++ b/app/assets/javascripts/search/topbar/components/app.vue @@ -5,7 +5,8 @@ import { mapState, mapActions } from 'vuex'; 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 { ZOEKT_SEARCH_TYPE, ADVANCED_SEARCH_TYPE } from '~/search/store/constants'; +import { SYNTAX_OPTIONS_ADVANCED_DOCUMENT, SYNTAX_OPTIONS_ZOEKT_DOCUMENT } from '../constants'; import GroupFilter from './group_filter.vue'; import ProjectFilter from './project_filter.vue'; @@ -42,11 +43,6 @@ export default { required: false, default: () => ({}), }, - elasticsearchEnabled: { - type: Boolean, - required: false, - default: false, - }, defaultBranchName: { type: String, required: false, @@ -54,7 +50,7 @@ export default { }, }, computed: { - ...mapState(['query']), + ...mapState(['query', 'searchType']), search: { get() { return this.query ? this.query.search : ''; @@ -67,7 +63,15 @@ export default { return !parseBoolean(this.query.snippets); }, showSyntaxOptions() { - return this.elasticsearchEnabled && this.isDefaultBranch; + return ( + (this.searchType === ZOEKT_SEARCH_TYPE || this.searchType === ADVANCED_SEARCH_TYPE) && + this.isDefaultBranch + ); + }, + documentBasedOnSearchType() { + return this.searchType === ZOEKT_SEARCH_TYPE + ? SYNTAX_OPTIONS_ZOEKT_DOCUMENT + : SYNTAX_OPTIONS_ADVANCED_DOCUMENT; }, isDefaultBranch() { return !this.query.repository_ref || this.query.repository_ref === this.defaultBranchName; @@ -82,7 +86,6 @@ export default { this.$refs.markdownDrawer.toggleDrawer(); }, }, - SYNTAX_OPTIONS_DOCUMENT, }; </script> @@ -104,10 +107,7 @@ export default { @click="onToggleDrawer" >{{ $options.i18n.syntaxOptionsLabel }} </gl-button> - <markdown-drawer - ref="markdownDrawer" - :document-path="$options.SYNTAX_OPTIONS_DOCUMENT" - /> + <markdown-drawer ref="markdownDrawer" :document-path="documentBasedOnSearchType" /> </template> </div> <gl-search-box-by-click diff --git a/app/assets/javascripts/search/topbar/constants.js b/app/assets/javascripts/search/topbar/constants.js index 5b1c5819f2b..1ad40fbe3db 100644 --- a/app/assets/javascripts/search/topbar/constants.js +++ b/app/assets/javascripts/search/topbar/constants.js @@ -20,4 +20,5 @@ export const PROJECT_DATA = { fullName: 'name_with_namespace', }; -export const SYNTAX_OPTIONS_DOCUMENT = 'drawers/drawers/advanced_search_syntax.md'; +export const SYNTAX_OPTIONS_ADVANCED_DOCUMENT = 'drawers/drawers/advanced_search_syntax.md'; +export const SYNTAX_OPTIONS_ZOEKT_DOCUMENT = 'drawers/drawers/exact_code_search_syntax.md'; diff --git a/app/assets/javascripts/search/topbar/index.js b/app/assets/javascripts/search/topbar/index.js index d6e16085c28..aad7445ebdc 100644 --- a/app/assets/javascripts/search/topbar/index.js +++ b/app/assets/javascripts/search/topbar/index.js @@ -11,18 +11,10 @@ export const initTopbar = (store) => { return false; } - const { - groupInitialJson, - projectInitialJson, - elasticsearchEnabled, - defaultBranchName, - } = el.dataset; + const { groupInitialJson, projectInitialJson, defaultBranchName } = el.dataset; const groupInitialJsonParsed = JSON.parse(groupInitialJson); const projectInitialJsonParsed = JSON.parse(projectInitialJson); - const elasticsearchEnabledParsed = elasticsearchEnabled - ? JSON.parse(elasticsearchEnabled) - : false; return new Vue({ el, @@ -32,7 +24,6 @@ export const initTopbar = (store) => { props: { groupInitialJson: groupInitialJsonParsed, projectInitialJson: projectInitialJsonParsed, - elasticsearchEnabled: elasticsearchEnabledParsed, defaultBranchName, }, }); diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue index 7f0a049a6ad..395bdad5dcc 100644 --- a/app/assets/javascripts/security_configuration/components/feature_card.vue +++ b/app/assets/javascripts/security_configuration/components/feature_card.vue @@ -95,6 +95,9 @@ export default { showSecondaryConfigurationHelpPath() { return Boolean(this.available && this.feature.secondary?.configurationHelpPath); }, + hyphenatedFeature() { + return this.feature.type.replace(/_/g, '-'); + }, }, methods: { onError(message) { @@ -167,7 +170,7 @@ export default { :href="feature.configurationPath" variant="confirm" :category="configurationButton.category" - :data-testid="`${feature.type}_enable_button`" + :data-testid="`${hyphenatedFeature}-enable-button`" class="gl-mt-5" > {{ configurationButton.text }} @@ -179,7 +182,7 @@ export default { variant="confirm" :category="manageViaMrButtonCategory" class="gl-mt-5" - :data-testid="`${feature.type}_mr_button`" + :data-testid="`${hyphenatedFeature}-mr-button`" @error="onError" /> diff --git a/app/assets/javascripts/sentry/init_sentry.js b/app/assets/javascripts/sentry/init_sentry.js index dbd12dc36ce..6f32c8c4165 100644 --- a/app/assets/javascripts/sentry/init_sentry.js +++ b/app/assets/javascripts/sentry/init_sentry.js @@ -4,11 +4,10 @@ import { defaultStackParser, makeFetchTransport, defaultIntegrations, + BrowserTracing, // exports captureException, - captureMessage, - withScope, SDK_VERSION, } from 'sentrybrowser'; @@ -19,6 +18,8 @@ const initSentry = () => { const hub = getCurrentHub(); + const page = document?.body?.dataset?.page; + const client = new BrowserClient({ // Sentry.init(...) options dsn: gon.sentry_dsn, @@ -37,7 +38,19 @@ const initSentry = () => { // https://github.com/getsentry/sentry-javascript/blob/7.66.0/MIGRATION.md#explicit-client-options transport: makeFetchTransport, stackParser: defaultStackParser, - integrations: defaultIntegrations, + integrations: [ + ...defaultIntegrations, + new BrowserTracing({ + beforeNavigate(context) { + return { + ...context, + // `page` acts as transaction name for performance tracing. + // If missing, use default Sentry behavior: window.location.pathname + name: page || window?.location?.pathname, + }; + }, + }), + ], }); hub.bindClient(client); @@ -45,7 +58,7 @@ const initSentry = () => { hub.setTags({ revision: gon.revision, feature_category: gon.feature_category, - page: document?.body?.dataset?.page, + page, }); if (gon.current_user_id) { @@ -68,8 +81,6 @@ const initSentry = () => { // eslint-disable-next-line no-underscore-dangle window._Sentry = { captureException, - captureMessage, - withScope, SDK_VERSION, // used to verify compatibility with the Sentry instance }; }; diff --git a/app/assets/javascripts/sentry/sentry_browser_wrapper.js b/app/assets/javascripts/sentry/sentry_browser_wrapper.js index fbfd5d4f458..03cf53fabef 100644 --- a/app/assets/javascripts/sentry/sentry_browser_wrapper.js +++ b/app/assets/javascripts/sentry/sentry_browser_wrapper.js @@ -13,19 +13,3 @@ export const captureException = (...args) => { Sentry?.captureException(...args); }; - -/** @type {import('@sentry/core').captureMessage} */ -export const captureMessage = (...args) => { - // eslint-disable-next-line no-underscore-dangle - const Sentry = window._Sentry; - - Sentry?.captureMessage(...args); -}; - -/** @type {import('@sentry/core').withScope} */ -export const withScope = (...args) => { - // eslint-disable-next-line no-underscore-dangle - const Sentry = window._Sentry; - - Sentry?.withScope(...args); -}; diff --git a/app/assets/javascripts/settings_panels.js b/app/assets/javascripts/settings_panels.js index da948cc85b6..1eee7a932a4 100644 --- a/app/assets/javascripts/settings_panels.js +++ b/app/assets/javascripts/settings_panels.js @@ -52,7 +52,7 @@ export function initTrackProductAnalyticsExpanded() { const $analyticsSection = $('#js-product-analytics-settings'); $analyticsSection.on('click.toggleSection', '.js-settings-toggle', () => { if (isExpanded($analyticsSection)) { - InternalEvents.track_event('user_viewed_cluster_configuration'); + InternalEvents.trackEvent('user_viewed_cluster_configuration'); } }); } diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue index d65c950b33a..81fc2267622 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue @@ -50,7 +50,7 @@ export default { :width="imgSize" :class="`s${imgSize}`" class="avatar avatar-inline m-0" - data-qa-selector="avatar_image" + data-testid="avatar-image" /> <gl-icon v-if="hasMergeIcon" name="warning-solid" aria-hidden="true" class="merge-icon" /> </span> diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue index ef7f12f273f..a4090800ae6 100644 --- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -92,7 +92,6 @@ export default { <div class="gl-ml-3 gl-line-height-normal gl-display-grid gl-align-items-center" data-testid="username" - data-qa-selector="username" > <user-name-with-status :name="user.name" :availability="userAvailability(user)" /> </div> @@ -104,7 +103,6 @@ export default { category="tertiary" size="small" data-testid="user-list-more-button" - data-qa-selector="more_assignees_link" @click="toggleShowLess" > <template v-if="showLess"> 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 7a1853b1b46..90c3fb0039d 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue @@ -1,7 +1,7 @@ <script> import { GlSprintf, GlButton } from '@gitlab/ui'; import { createAlert } from '~/alert'; -import { TYPE_ISSUE, TYPE_TEST_CASE, IssuableTypeText } from '~/issues/constants'; +import { TYPE_ISSUE, TYPE_TEST_CASE, issuableTypeText } from '~/issues/constants'; import { __, sprintf } from '~/locale'; import { confidentialityQueries } from '../../queries/constants'; @@ -80,7 +80,7 @@ export default { : __('at least the Reporter role'); }, issuableTypeText() { - return IssuableTypeText[this.issuableType]; + return issuableTypeText[this.issuableType]; }, commentText() { return this.isTestCase ? '' : __(' and leave a comment on'); 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 295d37671cc..ecccb0abfd1 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue @@ -135,6 +135,7 @@ export default { :tracking="$options.tracking" :loading="isLoading" class="block confidentiality" + data-testid="sidebar-confidentiality" > <template #collapsed> <div> diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue index 3e4297887f0..a1b7e65474a 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue @@ -183,7 +183,7 @@ export default { ref="searchInput" v-model="searchKey" :disabled="labelsFetchInProgress" - data-qa-selector="dropdown_input_field" + data-testid="dropdown-input-field" /> </div> <div ref="labelsListContainer" class="dropdown-content" data-testid="dropdown-content"> diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue index 154a8e866d0..377200ab804 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue @@ -71,8 +71,7 @@ export default { size="small" class="dropdown-header-button gl-p-0!" icon="close" - data-testid="close-button" - data-qa-selector="close_labels_dropdown_button" + data-testid="close-labels-dropdown-button" @click="$emit('closeDropdown')" /> </div> diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue index 57e3ee4aaa5..f2ce02526e7 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue @@ -108,7 +108,7 @@ export default { v-for="label in sortedSelectedLabels" :key="label.id" class="hide-collapsed" - data-qa-selector="selected_label_content" + data-testid="selected-label-content" :data-qa-label-name="label.title" :title="label.title" :description="label.description" diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue index f9a9cc316c1..ac52e4dbf3f 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue @@ -362,7 +362,6 @@ export default { 'is-embedded': isDropdownVariantEmbedded(variant), }" data-testid="sidebar-labels" - data-qa-selector="labels_block" > <template v-if="isDropdownVariantSidebar(variant)"> <sidebar-editable-item diff --git a/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue b/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue index 24afb25e403..f2097ce589e 100644 --- a/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue +++ b/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue @@ -110,7 +110,7 @@ export default { :issuable-attribute="$options.issuableAttribute" :issuable-type="issuableType" :workspace-type="workspaceType" - data-qa-selector="issuable_milestone_dropdown" + data-testid="issuable-milestone-dropdown" @change="handleChange" > <template #footer> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue index 55bb214aa65..92461183711 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue @@ -43,7 +43,7 @@ export default { data-track-action="click_edit_button" data-track-label="right_sidebar" data-track-property="reviewer" - data-qa-selector="reviewers_edit_button" + data-testid="reviewers-edit-button" > {{ __('Edit') }} </a> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue index a3282932f84..ee9edd6a022 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue @@ -1,5 +1,6 @@ <!-- eslint-disable vue/multi-word-component-names --> <script> +import { GlButton } from '@gitlab/ui'; // NOTE! For the first iteration, we are simply copying the implementation of Assignees // It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 import { TYPE_ISSUE } from '~/issues/constants'; @@ -11,6 +12,7 @@ export default { // eslint-disable-next-line @gitlab/require-i18n-strings name: 'Reviewers', components: { + GlButton, CollapsedReviewerList, UncollapsedReviewerList, }, @@ -64,15 +66,16 @@ export default { {{ __('None') }} <template v-if="editable"> - - <button - type="button" - class="gl-button btn-link gl-reset-color!" + <gl-button + category="tertiary" + variant="link" + class="gl-ml-2" data-testid="assign-yourself" data-qa-selector="assign_yourself_button" @click="assignSelf" > - {{ __('assign yourself') }} - </button> + <span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span> + </gl-button> </template> </span> diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index 7fde43a360d..28b88a59405 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -308,7 +308,7 @@ export default { v-gl-tooltip="tooltipText" class="gl-reset-color gl-hover-text-blue-800" :href="attributeUrl" - :data-qa-selector="`${formatIssuableAttribute.snake}_link`" + :data-testid="`${formatIssuableAttribute.kebab}-link`" > {{ attributeTitle }} <span v-if="isAttributeOverdue(currentAttribute)">{{ $options.i18n.expired }}</span> 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 568962cddc7..866db2a43b8 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue @@ -1,7 +1,7 @@ <script> import { + GlButton, GlDisclosureDropdownItem, - GlDropdownForm, GlIcon, GlLoadingIcon, GlToggle, @@ -30,8 +30,8 @@ export default { GlTooltip: GlTooltipDirective, }, components: { + GlButton, GlDisclosureDropdownItem, - GlDropdownForm, GlIcon, GlLoadingIcon, GlToggle, @@ -130,6 +130,12 @@ export default { canSubscribe() { return this.emailsDisabled || !this.isLoggedIn; }, + isNotificationsTodosButtons() { + return this.glFeatures.notificationsTodosButtons && this.glFeatures.movedMrSidebar; + }, + isMergeRequest() { + return this.issuableType === 'merge_request'; + }, }, methods: { setSubscribed(subscribed) { @@ -194,20 +200,8 @@ export default { </script> <template> - <gl-dropdown-form v-if="isMovedMrSidebar && isIssuable" class="gl-dropdown-item"> - <div class="gl-px-5 gl-pb-2 gl-pt-1"> - <gl-toggle - :value="subscribed" - :label="$options.i18n.notifications" - class="merge-request-notification-toggle" - label-position="left" - data-testid="notification-toggle" - @change="toggleSubscribed" - /> - </div> - </gl-dropdown-form> <gl-disclosure-dropdown-item - v-else-if="isMovedMrSidebar" + v-if="isMovedMrSidebar && !isNotificationsTodosButtons" data-testid="notification-toggle" @action="toggleSubscribed" > @@ -220,6 +214,32 @@ export default { /> </template> </gl-disclosure-dropdown-item> + <div v-else-if="isNotificationsTodosButtons" :class="{ 'inline-block': !isMergeRequest }"> + <gl-button + ref="tooltip" + v-gl-tooltip.hover.top + category="secondary" + data-testid="subscribe-button" + class="hide-collapsed" + :title="notificationTooltip" + :class="{ 'gl-ml-2': isIssuable, 'btn-icon': isNotificationsTodosButtons }" + @click="toggleSubscribed" + > + <gl-icon :name="notificationIcon" :size="16" :class="{ 'gl-fill-blue-500': subscribed }" /> + </gl-button> + <gl-button + v-if="!isMergeRequest" + ref="tooltip" + v-gl-tooltip.left.viewport + category="secondary" + data-testid="subscribe-button" + :title="notificationTooltip" + class="sidebar-collapsed-icon sidebar-collapsed-container gl-rounded-0! gl-shadow-none!" + @click="toggleSubscribed" + > + <gl-icon :name="notificationIcon" :size="16" :class="{ 'gl-fill-blue-500': subscribed }" /> + </gl-button> + </div> <sidebar-editable-item v-else ref="editable" 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 index 9b582ba41ed..f11c7e6ac4d 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue @@ -206,7 +206,7 @@ export default { :value="spentAt" show-clear-button autocomplete="off" - size="small" + width="small" @input="updateSpentAtDate" @clear="updateSpentAtDate(null)" /> 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 1099dcb832f..f2257adb79c 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 @@ -114,6 +114,9 @@ export default { tootltipTitle() { return todoLabel(this.hasTodo); }, + isNotificationsTodosButtons() { + return this.glFeatures.notificationsTodosButtons && this.glFeatures.movedMrSidebar; + }, }, methods: { toggleTodo() { @@ -183,8 +186,26 @@ export default { </script> <template> - <div data-testid="sidebar-todo"> + <div data-testid="sidebar-todo" :class="{ 'inline-block': !isMergeRequest }"> + <todo-button + v-if="isNotificationsTodosButtons" + v-gl-tooltip.hover.top + :title="tootltipTitle" + :issuable-type="issuableType" + :issuable-id="issuableId" + :is-todo="hasTodo" + :disabled="isLoading" + class="hide-collapsed btn-icon" + @click.stop.prevent="toggleTodo" + > + <gl-icon + v-if="isNotificationsTodosButtons" + :class="{ 'todo-undone gl-fill-blue-500': hasTodo }" + :name="collapsedButtonIcon" + /> + </todo-button> <todo-button + v-else :issuable-type="issuableType" :issuable-id="issuableId" :is-todo="hasTodo" diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo_button.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo_button.vue index b49b8fc389b..2aa79b45093 100644 --- a/app/assets/javascripts/sidebar/components/todo_toggle/todo_button.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo_button.vue @@ -39,6 +39,6 @@ export default { <template> <gl-button v-bind="$attrs" :aria-label="buttonLabel" @click="onToggle($event)"> - {{ buttonLabel }} + <slot>{{ buttonLabel }}</slot> </gl-button> </template> diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 1f3119e14db..4b6dbdcc2c9 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -15,7 +15,6 @@ import { __ } from '~/locale'; import { apolloProvider } from '~/graphql_shared/issuable_client'; import Translate from '~/vue_shared/translate'; import UserSelect from '~/vue_shared/components/user_select/user_select.vue'; -import NewHeaderActionsPopover from '~/issues/show/components/new_header_actions_popover.vue'; import CollapsedAssigneeList from './components/assignees/collapsed_assignee_list.vue'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import SidebarAssigneesWidget from './components/assignees/sidebar_assignees_widget.vue'; @@ -800,21 +799,6 @@ export function mountAssigneesDropdown() { }); } -function mountNewIssuePopover() { - const el = document.querySelector('.js-sidebar-header-popover'); - - if (!el) { - return null; - } - - return new Vue({ - el, - name: 'NewHeaderActionsPopover', - render: (createElement) => - createElement(NewHeaderActionsPopover, { props: { issueType: TYPE_MERGE_REQUEST } }), - }); -} - const isAssigneesWidgetShown = (isInIssuePage() || isInDesignPage() || isInMRPage()) && gon.features.issueAssigneesWidget; @@ -840,7 +824,6 @@ export function mountSidebar(mediator, store) { mountSidebarSeverityWidget(); mountSidebarEscalationStatus(); mountMoveIssueButton(); - mountNewIssuePopover(); } export { getSidebarOptions }; diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index 9e80210de51..aa3f33989c8 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -232,8 +232,7 @@ export default { <gl-form-input id="snippet-title" v-model="snippet.title" - data-testid="snippet-title-input" - data-qa-selector="snippet_title_field" + data-testid="snippet-title-input-field" :autofocus="true" /> </gl-form-group> @@ -261,7 +260,7 @@ export default { category="primary" type="submit" variant="confirm" - data-qa-selector="submit_button" + data-testid="submit-button" :disabled="isUpdating" >{{ saveButtonLabel }}</gl-button > diff --git a/app/assets/javascripts/snippets/components/embed_dropdown.vue b/app/assets/javascripts/snippets/components/embed_dropdown.vue index 17312c2373b..1510cc01810 100644 --- a/app/assets/javascripts/snippets/components/embed_dropdown.vue +++ b/app/assets/javascripts/snippets/components/embed_dropdown.vue @@ -53,7 +53,7 @@ export default { :aria-label="$options.MSG_COPY" :data-clipboard-text="value" icon="copy-to-clipboard" - data-qa-selector="copy_button" + data-testid="copy-button" :data-qa-action="name" /> </template> diff --git a/app/assets/javascripts/snippets/components/show.vue b/app/assets/javascripts/snippets/components/show.vue index 549b1bdd209..7a60fc6d26c 100644 --- a/app/assets/javascripts/snippets/components/show.vue +++ b/app/assets/javascripts/snippets/components/show.vue @@ -68,14 +68,14 @@ export default { <embed-dropdown v-if="embeddable" :url="snippet.webUrl" - data-qa-selector="snippet_embed_dropdown" + data-testid="snippet-embed-dropdown" /> <clone-dropdown-button v-if="canBeCloned" class="gl-ml-3" :ssh-link="snippet.sshUrlToRepo" :http-link="snippet.httpUrlToRepo" - data-qa-selector="clone_button" + data-testid="clone-button" /> </div> <gl-alert v-if="hasUnretrievableBlobs" variant="danger" class="gl-mb-3" :dismissible="false"> diff --git a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue index 59f7c8d8d97..ca1d9f858a5 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue @@ -157,10 +157,9 @@ export default { </gl-form-group> <gl-button :disabled="!canAdd" - data-testid="add_button" + data-testid="add-button" class="gl-my-3" variant="dashed" - data-qa-selector="add_file_button" @click="addBlob" >{{ addLabel }}</gl-button > diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue index 021bd23781e..9b0a1db23f2 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue @@ -69,11 +69,11 @@ export default { }; </script> <template> - <div class="file-holder snippet" data-qa-selector="file_holder_container"> + <div class="file-holder snippet" data-testid="file-holder-container"> <blob-header-edit :id="inputId" :value="blob.path" - data-qa-selector="file_name_field" + data-testid="file-name-field" :can-delete="canDelete" :show-delete="showDelete" @input="notifyAboutUpdates({ path: $event })" diff --git a/app/assets/javascripts/snippets/components/snippet_description_edit.vue b/app/assets/javascripts/snippets/components/snippet_description_edit.vue index 3ce7ea231ff..93d52890675 100644 --- a/app/assets/javascripts/snippets/components/snippet_description_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_description_edit.vue @@ -36,7 +36,7 @@ export default { <gl-form-input class="form-control" :placeholder="s__('Snippets|Describe what your snippet does or how to use it…')" - data-qa-selector="description_placeholder" + data-testid="description-placeholder" /> </div> <markdown-field @@ -54,7 +54,7 @@ export default { :value="value" class="note-textarea js-gfm-input js-autosize markdown-area" dir="auto" - data-qa-selector="snippet_description_field" + data-testid="snippet-description-field" data-supports-quick-actions="false" :aria-label="__('Description')" :placeholder="__('Write a comment or drag your files here…')" diff --git a/app/assets/javascripts/snippets/components/snippet_description_view.vue b/app/assets/javascripts/snippets/components/snippet_description_view.vue index ab2ff6e0ef8..9eae096d6f2 100644 --- a/app/assets/javascripts/snippets/components/snippet_description_view.vue +++ b/app/assets/javascripts/snippets/components/snippet_description_view.vue @@ -20,7 +20,7 @@ export default { }; </script> <template> - <markdown-field-view class="snippet-description" data-qa-selector="snippet_description_content"> + <markdown-field-view class="snippet-description" data-testid="snippet-description-content"> <div v-safe-html:[$options.safeHtmlConfig]="description" class="md js-snippet-description" diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index 881e06113d9..56ea931fc8c 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -216,7 +216,7 @@ export default { <div class="detail-page-header-body"> <div class="snippet-box has-tooltip d-flex align-items-center gl-mr-2 mb-1" - data-qa-selector="snippet_container" + data-testid="snippet-container" :title="snippetVisibilityLevelDescription" data-container="body" > @@ -267,7 +267,7 @@ export default { :category="action.category" :class="action.cssClass" :href="action.href" - data-qa-selector="snippet_action_button" + data-testid="snippet-action-button" :data-qa-action="action.text" @click="action.click ? action.click() : undefined" >{{ action.text }}</gl-button @@ -321,8 +321,7 @@ export default { variant="danger" category="primary" :disabled="isLoading" - data-qa-selector="delete_snippet_button" - data-testid="delete-snippet" + data-testid="delete-snippet-button" @click="deleteSnippet" > <gl-loading-icon v-if="isLoading" size="sm" inline /> diff --git a/app/assets/javascripts/snippets/components/snippet_title.vue b/app/assets/javascripts/snippets/components/snippet_title.vue index 2cf7a1e267b..0e4dbf55963 100644 --- a/app/assets/javascripts/snippets/components/snippet_title.vue +++ b/app/assets/javascripts/snippets/components/snippet_title.vue @@ -20,7 +20,7 @@ export default { </script> <template> <div class="snippet-header limited-header-width"> - <h2 class="snippet-title gl-mt-0 mb-3" data-qa-selector="snippet_title_content"> + <h2 class="snippet-title gl-mt-0 mb-3" data-testid="snippet-title-content"> {{ snippet.title }} </h2> diff --git a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue index 24dd978585c..37d10cffc78 100644 --- a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue @@ -57,7 +57,7 @@ export default { <gl-icon :size="16" :name="option.icon" /> <span class="font-weight-bold ml-1 js-visibility-option" - data-qa-selector="visibility_content" + data-testid="visibility-content" :data-qa-visibility="option.label" >{{ option.label }}</span > diff --git a/app/assets/javascripts/super_sidebar/components/brand_logo.vue b/app/assets/javascripts/super_sidebar/components/brand_logo.vue index 02cf36fb053..c280c03591b 100644 --- a/app/assets/javascripts/super_sidebar/components/brand_logo.vue +++ b/app/assets/javascripts/super_sidebar/components/brand_logo.vue @@ -26,7 +26,7 @@ export default { <template> <a - v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.homepage" + v-gl-tooltip:super-sidebar.bottom="$options.i18n.homepage" class="brand-logo" :href="rootPath" data-track-action="click_link" @@ -46,7 +46,7 @@ export default { <span v-else v-safe-html="$options.logo" - aria-hidden + aria-hidden="true" data-testid="brand-header-default-logo" ></span> </a> diff --git a/app/assets/javascripts/super_sidebar/components/counter.vue b/app/assets/javascripts/super_sidebar/components/counter.vue index c0e1959fba4..49efc5ab5b9 100644 --- a/app/assets/javascripts/super_sidebar/components/counter.vue +++ b/app/assets/javascripts/super_sidebar/components/counter.vue @@ -15,7 +15,7 @@ export default { href: { type: String, required: false, - default: '', + default: null, }, icon: { type: String, diff --git a/app/assets/javascripts/super_sidebar/components/create_menu.vue b/app/assets/javascripts/super_sidebar/components/create_menu.vue index d1e96479631..279e689bd8d 100644 --- a/app/assets/javascripts/super_sidebar/components/create_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/create_menu.vue @@ -14,7 +14,7 @@ import { import { DROPDOWN_Y_OFFSET, IMPERSONATING_OFFSET } from '../constants'; // Left offset required for the dropdown to be aligned with the super sidebar -const DROPDOWN_X_OFFSET_BASE = -179; +const DROPDOWN_X_OFFSET_BASE = -177; const DROPDOWN_X_OFFSET_IMPERSONATING = DROPDOWN_X_OFFSET_BASE + IMPERSONATING_OFFSET; export default { @@ -62,7 +62,7 @@ export default { <template> <gl-disclosure-dropdown - v-gl-tooltip:super-sidebar.hover.bottom="dropdownOpen ? '' : $options.i18n.createNew" + v-gl-tooltip:super-sidebar.bottom="dropdownOpen ? '' : $options.i18n.createNew" category="tertiary" icon="plus" no-caret diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue index 4cfc329f8b8..61fa360c41f 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue @@ -17,6 +17,7 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { sprintf } from '~/locale'; import { ARROW_DOWN_KEY, ARROW_UP_KEY, END_KEY, HOME_KEY, ESC_KEY } from '~/lib/utils/keys'; import { + COMMAND_PALETTE, MIN_SEARCH_TERM, SEARCH_DESCRIBED_BY_WITH_RESULTS, SEARCH_DESCRIBED_BY_DEFAULT, @@ -50,6 +51,7 @@ export default { name: 'GlobalSearchModal', SEARCH_MODAL_ID, i18n: { + COMMAND_PALETTE, SEARCH_DESCRIBED_BY_WITH_RESULTS, SEARCH_DESCRIBED_BY_DEFAULT, SEARCH_DESCRIBED_BY_UPDATED, @@ -279,6 +281,7 @@ export default { hide-footer hide-header-close scrollable + :title="$options.i18n.COMMAND_PALETTE" body-class="gl-p-0!" modal-class="global-search-modal" :centered="false" diff --git a/app/assets/javascripts/super_sidebar/components/help_center.vue b/app/assets/javascripts/super_sidebar/components/help_center.vue index 8ce82116194..069987d4006 100644 --- a/app/assets/javascripts/super_sidebar/components/help_center.vue +++ b/app/assets/javascripts/super_sidebar/components/help_center.vue @@ -38,7 +38,7 @@ export default { shortcuts: __('Keyboard shortcuts'), version: __('Your GitLab version'), whatsnew: __("What's new"), - chat: s__('TanukiBot|Ask GitLab Duo'), + chat: s__('TanukiBot|GitLab Duo Chat'), }, props: { sidebarData: { diff --git a/app/assets/javascripts/super_sidebar/components/menu_section.vue b/app/assets/javascripts/super_sidebar/components/menu_section.vue index 6b5002e1aa8..91b781b8235 100644 --- a/app/assets/javascripts/super_sidebar/components/menu_section.vue +++ b/app/assets/javascripts/super_sidebar/components/menu_section.vue @@ -152,20 +152,20 @@ export default { <gl-collapse :id="itemId" v-model="isExpanded" - :aria-label="item.title" class="gl-list-style-none gl-p-0 gl-m-0 gl-transition-duration-medium gl-transition-timing-function-ease" data-qa-selector="menu_section" :data-qa-section-name="item.title" - tag="ul" > <slot> - <nav-item - v-for="subItem of item.items" - :key="`${item.title}-${subItem.title}`" - :item="subItem" - @pin-add="(itemId) => $emit('pin-add', itemId)" - @pin-remove="(itemId) => $emit('pin-remove', itemId)" - /> + <ul :aria-label="item.title" class="gl-list-style-none gl-p-0 gl-m-0"> + <nav-item + v-for="subItem of item.items" + :key="`${item.title}-${subItem.title}`" + :item="subItem" + @pin-add="(itemId) => $emit('pin-add', itemId)" + @pin-remove="(itemId) => $emit('pin-remove', itemId)" + /> + </ul> </slot> </gl-collapse> </component> diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue index 5e0f8fffb0e..5416f86abeb 100644 --- a/app/assets/javascripts/super_sidebar/components/nav_item.vue +++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue @@ -7,6 +7,7 @@ import { TRACKING_UNKNOWN_ID, TRACKING_UNKNOWN_PANEL, } from '~/super_sidebar/constants'; +import eventHub from '../event_hub'; import NavItemLink from './nav_item_link.vue'; import NavItemRouterLink from './nav_item_router_link.vue'; @@ -69,16 +70,14 @@ export default { return { isMouseIn: false, canClickPinButton: false, + pillCount: this.item.pill_count, }; }, computed: { - pillData() { - return this.item.pill_count; - }, hasPill() { return ( - Number.isFinite(this.pillData) || - (typeof this.pillData === 'string' && this.pillData !== '') + Number.isFinite(this.pillCount) || + (typeof this.pillCount === 'string' && this.pillCount !== '') ); }, isPinnable() { @@ -145,6 +144,9 @@ export default { hasAvatar() { return Boolean(this.item.entity_id); }, + hasEndSpace() { + return this.hasPill || this.isPinnable || this.isFlyout; + }, avatarShape() { return this.item.avatar_shape || 'rect'; }, @@ -179,11 +181,21 @@ export default { if (this.item.is_active) { this.$el.scrollIntoView(false); } + + eventHub.$on('updatePillValue', this.updatePillValue); + }, + destroyed() { + eventHub.$off('updatePillValue', this.updatePillValue); }, methods: { togglePointerEvents() { this.canClickPinButton = this.isMouseIn; }, + updatePillValue({ value, itemId }) { + if (this.item.id === itemId) { + this.pillCount = value; + } + }, }, }; </script> @@ -236,7 +248,7 @@ export default { </div> </div> <slot name="actions"></slot> - <span v-if="hasPill || isPinnable" class="gl-text-right gl-relative gl-min-w-8"> + <span v-if="hasEndSpace" class="gl-text-right gl-relative gl-min-w-6"> <gl-badge v-if="hasPill" size="sm" @@ -246,7 +258,7 @@ export default { 'hide-on-focus-or-hover--target transition-opacity-on-hover--target': isPinnable, }" > - {{ pillData }} + {{ pillCount }} </gl-badge> </span> </component> diff --git a/app/assets/javascripts/super_sidebar/components/pinned_section.vue b/app/assets/javascripts/super_sidebar/components/pinned_section.vue index 5da45b52bf4..ea3e9e9df1f 100644 --- a/app/assets/javascripts/super_sidebar/components/pinned_section.vue +++ b/app/assets/javascripts/super_sidebar/components/pinned_section.vue @@ -102,7 +102,7 @@ export default { <draggable v-if="items.length > 0" v-model="draggableItems" - class="gl-p-0 gl-m-0" + class="gl-p-0 gl-m-0 gl-list-style-none" data-testid="pinned-nav-items" handle=".js-draggable-icon" tag="ul" diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue index 02488e99c0e..772072c0996 100644 --- a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue @@ -158,7 +158,11 @@ export default { <template> <div class="gl-p-2 gl-relative"> - <ul v-if="hasStaticItems" class="gl-p-0 gl-m-0" data-testid="static-items-section"> + <ul + v-if="hasStaticItems" + class="gl-list-style-none gl-p-0 gl-m-0" + data-testid="static-items-section" + > <nav-item v-for="item in staticItems" :key="item.id" :item="item" is-static /> </ul> <pinned-section @@ -174,7 +178,11 @@ export default { class="gl-my-2 gl-mx-4" data-testid="main-menu-separator" /> - <ul class="gl-p-0 gl-list-style-none" data-testid="non-static-items-section"> + <ul + aria-labelledby="super-sidebar-context-header" + class="gl-p-0 gl-list-style-none" + data-testid="non-static-items-section" + > <template v-for="item in nonStaticItems"> <menu-section v-if="isSection(item)" @@ -182,6 +190,7 @@ export default { :item="item" :separated="item.separated" :has-flyout="showFlyoutMenus" + tag="li" @pin-add="createPin" @pin-remove="destroyPin" /> @@ -189,7 +198,6 @@ export default { v-else :key="item.id" :item="item" - tag="li" @pin-add="createPin" @pin-remove="destroyPin" /> diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue index fe3e4a8199e..5f7cfce93b1 100644 --- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue @@ -36,7 +36,7 @@ export default { mixins: [Tracking.mixin()], i18n: { skipToMainContent: __('Skip to main content'), - primary: s__('Navigation|Primary'), + primaryNavigation: s__('Navigation|Primary navigation'), }, inject: ['showTrialStatusWidget'], props: { @@ -130,7 +130,9 @@ export default { <div> <div class="super-sidebar-overlay" @click="collapseSidebar"></div> <gl-button + v-if="sidebarData.is_logged_in" class="super-sidebar-skip-to gl-sr-only-focusable gl-fixed gl-left-0 gl-m-3" + data-testid="super-sidebar-skip-to" href="#content-body" variant="confirm" > @@ -138,7 +140,7 @@ export default { </gl-button> <nav id="super-sidebar" - :aria-label="$options.i18n.primary" + aria-labelledby="super-sidebar-heading" class="super-sidebar" :class="peekClasses" data-testid="super-sidebar" @@ -147,6 +149,9 @@ export default { @mouseenter="isMouseover = true" @mouseleave="isMouseover = false" > + <h2 id="super-sidebar-heading" class="gl-sr-only"> + {{ $options.i18n.primaryNavigation }} + </h2> <user-bar :has-collapse-button="!showOverlay" :sidebar-data="sidebarData" /> <div v-if="showTrialStatusWidget" class="gl-px-2 gl-py-2"> <trial-status-widget @@ -158,12 +163,12 @@ export default { class="contextual-nav gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-overflow-hidden" > <div class="gl-flex-grow-1 gl-overflow-auto" data-testid="nav-container"> - <h2 - class="gl-px-5 gl-pt-3 gl-pb-2 gl-m-0 gl-reset-line-height gl-font-sm super-sidebar-context-header" + <div + id="super-sidebar-context-header" + class="gl-px-5 gl-pt-3 gl-pb-2 gl-m-0 gl-reset-line-height gl-font-weight-bold gl-font-sm super-sidebar-context-header" > {{ sidebarData.current_context_header }} - </h2> - + </div> <sidebar-menu v-if="menuItems.length" :items="menuItems" diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue index 30ee18cc369..71c1460423e 100644 --- a/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue @@ -14,66 +14,82 @@ export default { }, mixins: [Tracking.mixin()], props: { - tooltipContainer: { + type: { type: String, required: false, - default: null, - }, - tooltipPlacement: { - type: String, - required: false, - default: 'right', + default: 'expand', }, }, i18n: { - collapseSidebar: __('Hide sidebar'), - expandSidebar: __('Keep sidebar visible'), primaryNavigationSidebar: __('Primary navigation sidebar'), }, + tooltipCollapse: { + placement: 'bottom', + container: 'super-sidebar', + title: __('Hide sidebar'), + }, + tooltipExpand: { + placement: 'right', + title: __('Keep sidebar visible'), + }, data() { return sidebarState; }, computed: { - canOpen() { - return this.isCollapsed || this.isPeek || this.isHoverPeek; + isTypeCollapse() { + return this.type === 'collapse'; }, - tooltipTitle() { - return this.canOpen ? this.$options.i18n.expandSidebar : this.$options.i18n.collapseSidebar; + isTypeExpand() { + return this.type === 'expand'; }, tooltip() { - return { - placement: this.tooltipPlacement, - container: this.tooltipContainer, - title: this.tooltipTitle, - }; + return this.isTypeExpand ? this.$options.tooltipExpand : this.$options.tooltipCollapse; }, ariaExpanded() { - return String(!this.canOpen); + return String(this.isTypeCollapse); }, }, + mounted() { + this.$root.$on('bv::tooltip::show', this.onTooltipShow); + }, + beforeUnmount() { + this.$root.$off('bv::tooltip::show', this.onTooltipShow); + }, methods: { toggle() { - this.track(this.canOpen ? 'nav_show' : 'nav_hide', { + this.track(this.isTypeExpand ? 'nav_show' : 'nav_hide', { label: 'nav_toggle', property: 'nav_sidebar', }); - toggleSuperSidebarCollapsed(!this.canOpen, true); + toggleSuperSidebarCollapsed(!this.isTypeExpand, true); this.focusOtherToggle(); }, focusOtherToggle() { this.$nextTick(() => { - const classSelector = this.canOpen ? JS_TOGGLE_EXPAND_CLASS : JS_TOGGLE_COLLAPSE_CLASS; + const classSelector = this.isTypeExpand ? JS_TOGGLE_COLLAPSE_CLASS : JS_TOGGLE_EXPAND_CLASS; const otherToggle = document.querySelector(`.${classSelector}`); otherToggle?.focus(); }); }, + onTooltipShow(bvEvent) { + if ( + bvEvent.target !== this.$el || + (this.isTypeCollapse && !this.isCollapsed) || + (this.isTypeExpand && this.isCollapsed) || + this.isPeek || + this.isHoverPeek + ) + return; + + bvEvent.preventDefault(); + }, }, }; </script> <template> <gl-button - v-gl-tooltip.hover="tooltip" + v-gl-tooltip="tooltip" aria-controls="super-sidebar" :aria-expanded="ariaExpanded" :aria-label="$options.i18n.primaryNavigationSidebar" diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue index 49aee4f3470..88ea4d828b7 100644 --- a/app/assets/javascripts/super_sidebar/components/user_bar.vue +++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue @@ -126,9 +126,8 @@ export default { <super-sidebar-toggle v-if="hasCollapseButton" :class="$options.JS_TOGGLE_COLLAPSE_CLASS" - tooltip-placement="bottom" - tooltip-container="super-sidebar" data-testid="super-sidebar-collapse-button" + type="collapse" /> <create-menu v-if="sidebarData.is_logged_in && sidebarData.create_new_menu_groups.length > 0" @@ -154,7 +153,7 @@ export default { class="gl-display-flex gl-justify-content-space-between gl-gap-2" > <counter - v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.issues" + v-gl-tooltip:super-sidebar.bottom="$options.i18n.issues" class="gl-flex-basis-third dashboard-shortcuts-issues" icon="issues" :count="userCounts.assigned_issues" @@ -172,7 +171,7 @@ export default { @hidden="mrMenuShown = false" > <counter - v-gl-tooltip:super-sidebar.hover.bottom="mrMenuShown ? '' : $options.i18n.mergeRequests" + v-gl-tooltip:super-sidebar.bottom="mrMenuShown ? '' : $options.i18n.mergeRequests" class="gl-w-full" icon="merge-request-open" :count="mergeRequestTotalCount" @@ -184,7 +183,7 @@ export default { /> </merge-request-menu> <counter - v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.todoList" + v-gl-tooltip:super-sidebar.bottom="$options.i18n.todoList" class="gl-flex-basis-third shortcuts-todos js-todos-count" icon="todo-done" :count="userCounts.todos" @@ -198,7 +197,7 @@ export default { </div> <button id="super-sidebar-search" - v-gl-tooltip.bottom.hover.html="searchTooltip" + v-gl-tooltip.bottom.html="searchTooltip" v-gl-modal="$options.SEARCH_MODAL_ID" class="counter gl-display-block gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-gray-900 gl-border-none gl-inset-border-1-gray-a-08 gl-line-height-1 gl-focus--focus gl-w-full" data-testid="super-sidebar-search-button" diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue index ed6c41e85c6..891e883b6c0 100644 --- a/app/assets/javascripts/super_sidebar/components/user_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue @@ -12,7 +12,7 @@ import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; import Tracking from '~/tracking'; import PersistentUserCallout from '~/persistent_user_callout'; import { USER_MENU_TRACKING_DEFAULTS, DROPDOWN_Y_OFFSET, IMPERSONATING_OFFSET } from '../constants'; -import UserNameGroup from './user_name_group.vue'; +import UserMenuProfileItem from './user_menu_profile_item.vue'; // Left offset required for the dropdown to be aligned with the super sidebar const DROPDOWN_X_OFFSET_BASE = -211; @@ -40,7 +40,7 @@ export default { GlDisclosureDropdownItem, GlButton, NewNavToggle, - UserNameGroup, + UserMenuProfileItem, }, directives: { SafeHtml, @@ -247,7 +247,10 @@ export default { </gl-button> </template> - <user-name-group :user="data" /> + <gl-disclosure-dropdown-group> + <user-menu-profile-item :user="data" /> + </gl-disclosure-dropdown-group> + <gl-disclosure-dropdown-group bordered> <gl-disclosure-dropdown-item v-if="data.status.can_update" diff --git a/app/assets/javascripts/super_sidebar/components/user_menu_profile_item.vue b/app/assets/javascripts/super_sidebar/components/user_menu_profile_item.vue new file mode 100644 index 00000000000..95255ce3d8e --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/user_menu_profile_item.vue @@ -0,0 +1,83 @@ +<script> +import { GlBadge, GlDisclosureDropdownItem, GlTooltip } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { s__ } from '~/locale'; +import { USER_MENU_TRACKING_DEFAULTS } from '../constants'; + +export default { + i18n: { + user: { + busy: s__('UserProfile|Busy'), + }, + }, + components: { + GlBadge, + GlDisclosureDropdownItem, + GlTooltip, + }, + directives: { + SafeHtml, + }, + props: { + user: { + required: true, + type: Object, + }, + }, + computed: { + menuItem() { + const item = { + text: this.user.name, + }; + if (this.user.has_link_to_profile) { + item.href = this.user.link_to_profile; + + item.extraAttrs = { + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'user_profile', + 'data-testid': 'user-profile-link', + }; + } + + return item; + }, + }, +}; +</script> + +<template> + <gl-disclosure-dropdown-item :item="menuItem"> + <template #list-item> + <span class="gl-display-flex gl-flex-direction-column"> + <span> + <span class="gl-font-weight-bold"> + {{ user.name }} + </span> + <gl-badge v-if="user.status.busy" size="sm" variant="warning"> + {{ $options.i18n.user.busy }} + </gl-badge> + </span> + + <span class="gl-text-gray-400 gl-word-break-all">@{{ user.username }}</span> + + <span + v-if="user.status.customized" + ref="statusTooltipTarget" + data-testid="user-menu-status" + class="gl-display-flex gl-align-items-baseline gl-mt-2 gl-font-sm" + > + <gl-emoji :data-name="user.status.emoji" class="gl-mr-1" /> + <span v-safe-html="user.status.message_html" class="gl-text-truncate"></span> + <gl-tooltip + v-if="user.status.message_html" + :target="() => $refs.statusTooltipTarget" + boundary="viewport" + placement="bottom" + > + <span v-safe-html="user.status.message_html"></span> + </gl-tooltip> + </span> + </span> + </template> + </gl-disclosure-dropdown-item> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/user_name_group.vue b/app/assets/javascripts/super_sidebar/components/user_name_group.vue deleted file mode 100644 index 3c8059387fa..00000000000 --- a/app/assets/javascripts/super_sidebar/components/user_name_group.vue +++ /dev/null @@ -1,91 +0,0 @@ -<script> -import { - GlBadge, - GlDisclosureDropdownGroup, - GlDisclosureDropdownItem, - GlTooltip, -} from '@gitlab/ui'; -import SafeHtml from '~/vue_shared/directives/safe_html'; -import { s__ } from '~/locale'; -import { USER_MENU_TRACKING_DEFAULTS } from '../constants'; - -export default { - i18n: { - user: { - busy: s__('UserProfile|Busy'), - }, - }, - components: { - GlBadge, - GlDisclosureDropdownGroup, - GlDisclosureDropdownItem, - GlTooltip, - }, - directives: { - SafeHtml, - }, - props: { - user: { - required: true, - type: Object, - }, - }, - computed: { - menuItem() { - const item = { - text: this.user.name, - }; - if (this.user.has_link_to_profile) { - item.href = this.user.link_to_profile; - - item.extraAttrs = { - ...USER_MENU_TRACKING_DEFAULTS, - 'data-track-label': 'user_profile', - 'data-testid': 'user_profile_link', - }; - } - - return item; - }, - }, -}; -</script> - -<template> - <gl-disclosure-dropdown-group> - <gl-disclosure-dropdown-item :item="menuItem"> - <template #list-item> - <span class="gl-display-flex gl-flex-direction-column"> - <span> - <span class="gl-font-weight-bold"> - {{ user.name }} - </span> - <gl-badge v-if="user.status.busy" size="sm" variant="warning"> - {{ $options.i18n.user.busy }} - </gl-badge> - </span> - - <span class="gl-text-gray-400">@{{ user.username }}</span> - - <span - v-if="user.status.customized" - ref="statusTooltipTarget" - data-testid="user-menu-status" - class="gl-display-flex gl-align-items-baseline gl-mt-2 gl-font-sm" - > - <gl-emoji :data-name="user.status.emoji" class="gl-mr-1" /> - <span v-safe-html="user.status.message_html" class="gl-text-truncate"></span> - <gl-tooltip - v-if="user.status.message_html" - :target="() => $refs.statusTooltipTarget" - boundary="viewport" - placement="bottom" - > - <span v-safe-html="user.status.message_html"></span> - </gl-tooltip> - </span> - </span> - </template> - </gl-disclosure-dropdown-item> - </gl-disclosure-dropdown-group> -</template> diff --git a/app/assets/javascripts/super_sidebar/constants.js b/app/assets/javascripts/super_sidebar/constants.js index 77bd8b4a734..e96dca3f365 100644 --- a/app/assets/javascripts/super_sidebar/constants.js +++ b/app/assets/javascripts/super_sidebar/constants.js @@ -59,4 +59,4 @@ export const DROPDOWN_Y_OFFSET = 4; export const NAV_ITEM_LINK_ACTIVE_CLASS = 'gl-bg-t-gray-a-08'; -export const IMPERSONATING_OFFSET = 32; +export const IMPERSONATING_OFFSET = 34; diff --git a/app/assets/javascripts/super_sidebar/event_hub.js b/app/assets/javascripts/super_sidebar/event_hub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/event_hub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js index de16161efb5..f9e488ea5ee 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js @@ -33,6 +33,8 @@ const getTrialStatusWidgetData = (sidebarData) => { companyName, glmContent, createHandRaiseLeadPath, + trackAction, + trackLabel, } = convertObjectPropsToCamelCase(sidebarData.trial_status_popover_data_attrs); return { @@ -47,6 +49,8 @@ const getTrialStatusWidgetData = (sidebarData) => { daysRemaining, targetId, createHandRaiseLeadPath, + trackAction, + trackLabel, trialEndDate: new Date(trialEndDate), user: { namespaceId, userName, firstName, lastName, companyName, glmContent }, }; diff --git a/app/assets/javascripts/super_sidebar/utils.js b/app/assets/javascripts/super_sidebar/utils.js index 97830a32d78..d2fb72adb85 100644 --- a/app/assets/javascripts/super_sidebar/utils.js +++ b/app/assets/javascripts/super_sidebar/utils.js @@ -59,19 +59,17 @@ const updateItemAccess = ( const neverAccessed = !lastAccessedOn; const shouldUpdate = neverAccessed || Math.abs(now - lastAccessedOn) / FIFTEEN_MINUTES_IN_MS > 1; - if (shouldUpdate && gon.features?.serverSideFrecentNamespaces) { - try { - axios({ - url: trackVisitsPath, - method: 'POST', - data: { - type: namespace, - id: contextItem.id, - }, - }); - } catch (e) { + if (shouldUpdate) { + axios({ + url: trackVisitsPath, + method: 'POST', + data: { + type: namespace, + id: contextItem.id, + }, + }).catch((e) => { Sentry.captureException(e); - } + }); } return { diff --git a/app/assets/javascripts/terms/components/app.vue b/app/assets/javascripts/terms/components/app.vue index 0ae97a47170..29099bcc366 100644 --- a/app/assets/javascripts/terms/components/app.vue +++ b/app/assets/javascripts/terms/components/app.vue @@ -5,7 +5,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import csrf from '~/lib/utils/csrf'; -import { trackTrialAcceptTerms } from '~/google_tag_manager'; +import { trackTrialAcceptTerms } from 'ee_else_ce/google_tag_manager'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; export default { diff --git a/app/assets/javascripts/terraform/components/empty_state.vue b/app/assets/javascripts/terraform/components/empty_state.vue index 551b5498571..5a71f0d66de 100644 --- a/app/assets/javascripts/terraform/components/empty_state.vue +++ b/app/assets/javascripts/terraform/components/empty_state.vue @@ -32,13 +32,14 @@ export default { </script> <template> - <gl-empty-state :svg-path="image" :title="$options.i18n.title"> + <gl-empty-state :svg-path="image" :svg-height="null" :title="$options.i18n.title"> <template #actions> - <gl-button variant="confirm" :href="$options.docsUrl"> + <gl-button variant="confirm" :href="$options.docsUrl" class="gl-mx-2 gl-mb-3"> {{ $options.i18n.buttonDoc }}</gl-button > <gl-button v-gl-modal-directive="$options.COMMAND_MODAL_ID" + class="gl-mx-2 gl-mb-3" data-testid="terraform-state-copy-init-command" icon="copy-to-clipboard" >{{ $options.i18n.buttonCopy }}</gl-button diff --git a/app/assets/javascripts/token_access/components/inbound_token_access.vue b/app/assets/javascripts/token_access/components/inbound_token_access.vue index 234ac0505b2..7e55f56279e 100644 --- a/app/assets/javascripts/token_access/components/inbound_token_access.vue +++ b/app/assets/javascripts/token_access/components/inbound_token_access.vue @@ -30,7 +30,7 @@ export default { 'CICD|Allow CI job tokens from the following projects to access this project', ), settingDisabledMessage: s__( - 'CICD|Enable feature to allow job token access by the following projects.', + 'CICD|Enable feature to limit job token access, so only the projects in this list can access this project with a CI/CD job token.', ), addProject: __('Add project'), cancel: __('Cancel'), diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js index 88b7f6d3532..46278152879 100644 --- a/app/assets/javascripts/tracking/constants.js +++ b/app/assets/javascripts/tracking/constants.js @@ -36,4 +36,3 @@ export const SERVICE_PING_SECURITY_CONFIGURATION_THREAT_MANAGEMENT_VISIT = 'users_visiting_security_configuration_threat_management'; export const SERVICE_PING_PIPELINE_SECURITY_VISIT = 'users_visiting_pipeline_security'; -export const USER_CONTEXT_SCHEMA = 'iglu:com.gitlab/user_context/jsonschema/1-0-0'; diff --git a/app/assets/javascripts/tracking/internal_events.js b/app/assets/javascripts/tracking/internal_events.js index 9bd0200cad1..d5bc428934c 100644 --- a/app/assets/javascripts/tracking/internal_events.js +++ b/app/assets/javascripts/tracking/internal_events.js @@ -1,12 +1,10 @@ import API from '~/api'; -import getStandardContext from './get_standard_context'; import Tracking from './tracking'; import { GITLAB_INTERNAL_EVENT_CATEGORY, LOAD_INTERNAL_EVENTS_SELECTOR, SERVICE_PING_SCHEMA, - USER_CONTEXT_SCHEMA, } from './constants'; import { Tracker } from './tracker'; import { InternalEventHandler, createInternalEventPayload } from './utils'; @@ -17,7 +15,7 @@ const InternalEvents = { * @param {string} event * @param {object} data */ - track_event(event, data = {}) { + trackEvent(event, data = {}) { const { context, ...rest } = data; const defaultContext = { @@ -34,6 +32,7 @@ const InternalEvents = { context: mergedContext, ...rest, }); + this.trackBrowserSDK(event); }, /** * Returns an implementation of this class in the form of @@ -42,8 +41,8 @@ const InternalEvents = { mixin() { return { methods: { - track_event(event, data = {}) { - InternalEvents.track_event(event, data); + trackEvent(event, data = {}) { + InternalEvents.trackEvent(event, data); }, }, }; @@ -62,7 +61,10 @@ const InternalEvents = { // eslint-disable-next-line no-param-reassign parent.internalEventsTrackingBound = true; - const handler = { name: 'click', func: (e) => InternalEventHandler(e, this.track_event) }; + const handler = { + name: 'click', + func: (e) => InternalEventHandler(e, this.trackEvent.bind(this)), + }; parent.addEventListener(handler.name, handler.func); return handler; }, @@ -81,7 +83,7 @@ const InternalEvents = { loadEvents.forEach((element) => { const action = createInternalEventPayload(element); if (action) { - this.track_event(action); + this.trackEvent(action); } }); @@ -91,21 +93,24 @@ const InternalEvents = { * Initialize browser sdk for product analytics */ initBrowserSDK() { - const standardContext = getStandardContext(); - if (window.glClient) { window.glClient.setDocumentTitle('GitLab'); window.glClient.page({ title: 'GitLab', - context: [ - { - schema: USER_CONTEXT_SCHEMA, - data: standardContext?.data || {}, - }, - ], }); } }, + /** + * track events for Product Analytics + * @param {string} event + */ + trackBrowserSDK(event) { + if (!Tracker.enabled()) { + return; + } + + window.glClient?.track(event); + }, }; export default InternalEvents; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue index c49c1316b1b..e16ccdd35b9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue @@ -83,12 +83,6 @@ export default { return btn.tooltipText; }, - actionButtonQaSelector(btn) { - if (btn.dataQaSelector) { - return btn.dataQaSelector; - } - return 'mr_widget_extension_actions_button'; - }, }, }; </script> @@ -105,7 +99,6 @@ export default { :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'" @@ -157,9 +150,8 @@ export default { :title="setTooltip(btn)" :href="btn.href" :target="btn.target" - :class="[{ 'gl-mr-1': index !== tertiaryButtons.length - 1 }, btn.class]" + :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'" 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 4ed470440cc..974b53caa15 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 @@ -24,10 +24,6 @@ export default { GlSprintf, }, mixins: [approvalsMixin, glFeatureFlagsMixin()], - provide: { - expandDetailsTooltip: __('Expand eligible approvers'), - collapseDetailsTooltip: __('Collapse eligible approvers'), - }, props: { mr: { type: Object, @@ -248,6 +244,8 @@ export default { is-collapsible collapse-on-desktop :collapsed="collapsed" + :expand-details-tooltip="__('Expand eligible approvers')" + :collapse-details-tooltip="__('Collapse eligible approvers')" @toggle="() => $emit('toggle')" > <template v-if="$apollo.queries.approvals.loading">{{ $options.FETCH_LOADING }}</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/checks/conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/checks/conflicts.vue new file mode 100644 index 00000000000..303952c787e --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/checks/conflicts.vue @@ -0,0 +1,77 @@ +<script> +import { __ } from '~/locale'; +import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; +import conflictsStateQuery from '../../queries/states/conflicts.query.graphql'; +import ActionButtons from '../action_buttons.vue'; +import MergeChecksMessage from './message.vue'; + +export default { + name: 'MergeChecksConflicts', + components: { + MergeChecksMessage, + ActionButtons, + }, + mixins: [mergeRequestQueryVariablesMixin], + apollo: { + state: { + query: conflictsStateQuery, + variables() { + return this.mergeRequestQueryVariables; + }, + update: (data) => data?.project?.mergeRequest, + }, + }, + props: { + check: { + type: Object, + required: true, + }, + mr: { + type: Object, + required: false, + default: () => ({}), + }, + }, + data() { + return { + state: {}, + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.state.loading; + }, + userPermissions() { + return this.state.userPermissions; + }, + showResolveButton() { + return ( + this.mr.conflictResolutionPath && + this.userPermissions.pushToSourceBranch && + !this.state.sourceBranchProtected + ); + }, + tertiaryActionsButtons() { + if (this.state.shouldBeRebased) return []; + + return [ + { + text: __('Resolve locally'), + class: 'js-check-out-modal-trigger', + }, + this.showResolveButton && { + text: __('Resolve conflicts'), + category: 'default', + href: this.mr.conflictResolutionPath, + }, + ].filter((b) => b); + }, + }, +}; +</script> + +<template> + <merge-checks-message :check="check"> + <action-buttons v-if="!isLoading" :tertiary-buttons="tertiaryActionsButtons" /> + </merge-checks-message> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/checks/message.vue b/app/assets/javascripts/vue_merge_request_widget/components/checks/message.vue new file mode 100644 index 00000000000..d0d749aa441 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/checks/message.vue @@ -0,0 +1,44 @@ +<script> +import StatusIcon from '../widget/status_icon.vue'; + +const ICON_NAMES = { + failed: 'failed', + allowed_to_fail: 'neutral', + passed: 'success', +}; + +export default { + name: 'MergeChecksMessage', + components: { + StatusIcon, + }, + props: { + check: { + type: Object, + required: true, + }, + mr: { + type: Object, + required: false, + default: () => ({}), + }, + }, + computed: { + iconName() { + return ICON_NAMES[this.check.result]; + }, + }, +}; +</script> + +<template> + <div class="gl-py-3 gl-pl-7"> + <div class="gl-display-flex"> + <status-icon :icon-name="iconName" :level="2" /> + <div class="gl-w-full gl-min-w-0"> + <div class="gl-display-flex">{{ check.failureReason }}</div> + </div> + <slot></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue index 8290e7e9232..1829b674455 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue @@ -37,7 +37,7 @@ export default { </script> <template> - <div class="deploy-heading gl-px-5"> + <div class="deploy-heading gl-pl-5 gl-pr-4"> <div class="ci-widget media"> <div class="media-body"> <div class="deploy-body"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.stories.js b/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.stories.js new file mode 100644 index 00000000000..1c57226f887 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.stories.js @@ -0,0 +1,91 @@ +import createMockApollo from 'helpers/mock_apollo_helper'; +import mergeChecksQuery from '../queries/merge_checks.query.graphql'; +import conflictsStateQuery from '../queries/states/conflicts.query.graphql'; +import MergeChecks from './merge_checks.vue'; + +const stylesheetsRequireCtx = require.context( + '../../../stylesheets', + true, + /(page_bundles\/merge_requests)\.scss$/, +); + +stylesheetsRequireCtx('./page_bundles/merge_requests.scss'); + +const defaultRender = (apolloProvider) => ({ + components: { MergeChecks }, + apolloProvider, + data() { + return { mr: { conflictResolutionPath: 'https://gitlab.com' } }; + }, + template: '<merge-checks :mr="mr" />', +}); + +const Template = ({ canMerge, failed, pushToSourceBranch }) => { + const requestHandlers = [ + [ + mergeChecksQuery, + () => + Promise.resolve({ + data: { + project: { + id: 1, + mergeRequest: { + id: 1, + userPermissions: { canMerge }, + mergeChecks: [ + { + failureReason: 'Unresolved discussions', + identifier: 'unresolved_discussions', + result: failed ? 'failed' : 'passed', + }, + { + failureReason: 'Resolve conflicts', + identifier: 'conflicts', + result: failed ? 'failed' : 'passed', + }, + ], + }, + }, + }, + }), + ], + [ + conflictsStateQuery, + () => + Promise.resolve({ + data: { + project: { + id: 1, + mergeRequest: { + id: 1, + shouldBeRebased: false, + sourceBranchProtected: false, + userPermissions: { pushToSourceBranch }, + }, + }, + }, + }), + ], + ]; + const apolloProvider = createMockApollo(requestHandlers); + + return defaultRender(apolloProvider); +}; + +const LoadingTemplate = () => { + const requestHandlers = [[mergeChecksQuery, () => new Promise(() => {})]]; + const apolloProvider = createMockApollo(requestHandlers); + + return defaultRender(apolloProvider); +}; + +export const Default = Template.bind({}); +Default.args = { canMerge: true, failed: true, pushToSourceBranch: true }; + +export const Loading = LoadingTemplate.bind({}); +Loading.args = {}; + +export default { + title: 'vue_merge_request_widget/merge_checks', + component: MergeChecks, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.vue b/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.vue new file mode 100644 index 00000000000..fa84c0a4a6f --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.vue @@ -0,0 +1,129 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; +import { n__, __, sprintf } from '~/locale'; +import mergeRequestQueryVariablesMixin from '../mixins/merge_request_query_variables'; +import mergeChecksQuery from '../queries/merge_checks.query.graphql'; +import StateContainer from './state_container.vue'; +import BoldText from './bold_text.vue'; + +const COMPONENTS = { + conflicts: () => import('./checks/conflicts.vue'), + default: () => import('./checks/message.vue'), +}; + +export default { + apollo: { + state: { + query: mergeChecksQuery, + skip() { + return !this.mr; + }, + variables() { + return this.mergeRequestQueryVariables; + }, + update: (data) => data?.project?.mergeRequest, + }, + }, + components: { + GlSkeletonLoader, + StateContainer, + BoldText, + }, + mixins: [mergeRequestQueryVariablesMixin], + props: { + mr: { + type: Object, + required: true, + }, + }, + data() { + return { + collapsed: true, + state: {}, + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.state.loading; + }, + statusIcon() { + return this.failedChecks.length ? 'failed' : 'success'; + }, + summaryText() { + if (!this.failedChecks.length) { + return this.state?.userPermissions?.canMerge + ? __('%{boldStart}Ready to merge!%{boldEnd}') + : __( + '%{boldStart}Ready to merge by members who can write to the target branch.%{boldEnd}', + ); + } + + return sprintf( + n__( + '%{boldStart}Merge blocked:%{boldEnd} %{count} check failed', + '%{boldStart}Merge blocked:%{boldEnd} %{count} checks failed', + this.failedChecks.length, + ), + { count: this.failedChecks.length }, + ); + }, + checks() { + return this.state.mergeChecks || []; + }, + failedChecks() { + return this.checks.filter((c) => c.result === 'failed'); + }, + }, + methods: { + toggleCollapsed() { + this.collapsed = !this.collapsed; + }, + checkComponent(check) { + return COMPONENTS[check.identifier] || COMPONENTS.default; + }, + }, +}; +</script> + +<template> + <div> + <state-container + :is-loading="isLoading" + :status="statusIcon" + is-collapsible + collapse-on-desktop + :collapsed="collapsed" + :expand-details-tooltip="__('Expand merge checks')" + :collapse-details-tooltip="__('Collapse merge checks')" + @toggle="toggleCollapsed" + > + <template v-if="isLoading" #loading> + <gl-skeleton-loader :width="334" :height="24"> + <rect x="0" y="0" width="24" height="24" rx="4" /> + <rect x="32" y="2" width="302" height="20" rx="4" /> + </gl-skeleton-loader> + </template> + <template v-else> + <bold-text :message="summaryText" /> + </template> + </state-container> + <div + v-if="!collapsed" + class="gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-relative gl-bg-gray-10" + data-testid="merge-checks-full" + > + <div class="gl-px-5"> + <component + :is="checkComponent(check)" + v-for="(check, index) in checks" + :key="index" + :class="{ + 'gl-border-b-solid gl-border-b-1 gl-border-gray-100': index !== checks.length - 1, + }" + :check="check" + :mr="mr" + /> + </div> + </div> + </div> +</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 bfcd4610379..2e104f2b93b 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 @@ -10,7 +10,7 @@ import { } 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 CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import { keepLatestDownstreamPipelines } from '~/ci/pipeline_details/utils/parsing_utils'; import PipelineArtifacts from '~/ci/pipelines_page/components/pipelines_artifacts.vue'; import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; @@ -21,7 +21,7 @@ import { MT_MERGE_STRATEGY } from '../constants'; export default { name: 'MRWidgetPipeline', components: { - CiIcon, + CiBadgeLink, GlLink, GlLoadingIcon, GlIcon, @@ -194,24 +194,23 @@ export default { </p> </template> <template v-else-if="hasPipeline"> - <a :href="status.details_path" class="gl-align-self-start gl-mt-2 gl-mr-3"> - <ci-icon :status="status" :size="24" class="gl-display-flex" /> - </a> + <ci-badge-link + :status="status" + :href="status.details_path" + size="md" + :show-text="false" + class="gl-align-self-start gl-mt-2 gl-mr-3" + /> <div class="ci-widget-container d-flex"> <div class="ci-widget-content"> <div class="media-body"> <div data-testid="pipeline-info-container" - data-qa-selector="merge_request_pipeline_info_content" class="gl-display-flex gl-flex-wrap gl-align-items-center gl-justify-content-space-between" > <p class="mr-pipeline-title gl-m-0! gl-mr-3! gl-font-weight-bold gl-text-gray-900"> {{ pipeline.details.event_type_name }} - <gl-link - :href="pipeline.path" - class="pipeline-id" - data-testid="pipeline-id" - data-qa-selector="pipeline_link" + <gl-link :href="pipeline.path" class="pipeline-id" data-testid="pipeline-id" >#{{ pipeline.id }}</gl-link > {{ pipeline.details.status.label }} @@ -240,7 +239,7 @@ export default { {{ s__('Pipeline|for') }} <gl-link :href="pipeline.commit.commit_path" - class="commit-sha gl-font-weight-normal" + class="commit-sha-container" data-testid="commit-link" >{{ pipeline.commit.short_id }}</gl-link > @@ -251,7 +250,7 @@ export default { v-safe-html="sourceBranchLink" :title="sourceBranch" truncate-target="child" - class="label-branch label-truncate gl-font-weight-normal" + class="label-branch label-truncate ref-container" /> </template> <template v-if="finishedAt"> 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 dd899701de0..2a18af90495 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 @@ -1,5 +1,6 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants'; import StatusIcon from './mr_widget_status_icon.vue'; import Actions from './action_buttons.vue'; @@ -13,14 +14,6 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - inject: { - expandDetailsTooltip: { - default: '', - }, - collapseDetailsTooltip: { - default: '', - }, - }, props: { isCollapsible: { type: Boolean, @@ -57,6 +50,16 @@ export default { required: false, default: () => [], }, + expandDetailsTooltip: { + required: false, + type: String, + default: __('Expand merge details'), + }, + collapseDetailsTooltip: { + required: false, + type: String, + default: __('Collapse merge details'), + }, }, computed: { wrapperClasses() { @@ -120,6 +123,7 @@ export default { <gl-button v-gl-tooltip :title="collapsed ? expandDetailsTooltip : collapseDetailsTooltip" + :aria-label="collapsed ? expandDetailsTooltip : collapseDetailsTooltip" :icon="collapsed ? 'chevron-lg-down' : 'chevron-lg-up'" category="tertiary" size="small" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue index 6299f0fcbb8..ec72b74daa2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue @@ -75,7 +75,6 @@ export default { actions.push({ text: this.cancelButtonText, loading: this.isCancellingAutoMerge, - dataQaSelector: 'cancel_auto_merge_button', class: 'js-cancel-auto-merge', testId: 'cancelAutomaticMergeButton', onClick: () => this.cancelAutomaticMerge(), diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue index 742f5d4de14..122abc7d034 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue @@ -51,7 +51,6 @@ export default { text: s__('mrWidget|Refresh now'), onClick: () => this.refresh(), testId: 'merge-request-failed-refresh-button', - dataQaSelector: 'merge_request_error_content', }, ]; }, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue index 4d906f29cb0..4454718a647 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue @@ -67,7 +67,7 @@ export default { actions.push({ text: this.revertLabel, tooltipText: this.revertTitle, - dataQaSelector: 'revert_button', + testId: 'revert-button', onClick: () => this.openRevertModal(), }); } else if (this.mr.revertInForkPath) { @@ -75,7 +75,7 @@ export default { text: this.revertLabel, tooltipText: this.revertTitle, href: this.mr.revertInForkPath, - dataQaSelector: 'revert_button', + testId: 'revert-button', dataMethod: 'post', }); } @@ -84,7 +84,7 @@ export default { actions.push({ text: this.cherryPickLabel, tooltipText: this.cherryPickTitle, - dataQaSelector: 'cherry_pick_button', + testId: 'cherry-pick-button', onClick: () => this.openCherryPickModal(), }); } else if (this.mr.cherryPickInForkPath) { @@ -92,7 +92,7 @@ export default { text: this.cherryPickLabel, tooltipText: this.cherryPickTitle, href: this.mr.cherryPickInForkPath, - dataQaSelector: 'cherry_pick_button', + testId: 'cherry-pick-button', dataMethod: 'post', }); } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index 415f58ea8e6..a4afdee4d49 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -230,7 +230,6 @@ export default { v-if="!rebasingError" class="gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-ml-0! gl-text-body! gl-md-mr-3" data-testid="rebase-message" - data-qa-selector="no_fast_forward_message_content" > <bold-text :message="$options.i18n.rebaseError" /> </span> @@ -247,7 +246,6 @@ export default { :loading="isMakingRequest" variant="confirm" size="small" - data-qa-selector="mr_rebase_button" data-testid="standard-rebase-button" class="gl-align-self-start" @click="tryRebase" 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 0ce8389579d..ac434c5be4e 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 @@ -634,7 +634,6 @@ export default { variant="confirm" :disabled="isMergeButtonDisabled" :loading="isMakingRequest" - data-qa-selector="merge_button" @click="handleMergeButtonClick(isAutoMergeAvailable)" >{{ mergeButtonText }}</gl-button > @@ -644,7 +643,6 @@ export default { :disabled="isMergeButtonDisabled" variant="confirm" data-testid="merge-immediately-dropdown" - data-qa-selector="merge_moment_dropdown" toggle-class="btn-icon js-merge-moment" > <template #button-content> @@ -655,7 +653,6 @@ export default { icon-name="warning" button-class="accept-merge-request" data-testid="merge-immediately-button" - data-qa-selector="merge_immediately_menu_item" @click="handleMergeImmediatelyButtonClick" > {{ __('Merge immediately') }} @@ -692,7 +689,7 @@ export default { <div v-else class="gl-w-full gl-order-n1 mr-widget-merge-details" - data-qa-selector="merged_status_content" + data-testid="merged-status-content" > <p v-if="showMergeDetailsHeader" class="gl-mb-2 gl-text-gray-900"> {{ __('Merge details') }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue index 9da754d01fc..00383418f2d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue @@ -32,7 +32,7 @@ export default { > <span class="gl-md-mr-3 gl-flex-grow-1 gl-ml-0! gl-text-body!" - data-qa-selector="head_mismatch_content" + data-testid="head-mismatch-content" > <bold-text :message="$options.i18n.I18N_SHA_MISMATCH.warningMessage" /> </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue index 97ef96fe382..f1bd5bb25bb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue @@ -46,7 +46,7 @@ export default { :disabled="isDisabled" name="squash" class="js-squash-checkbox gl-mr-2" - data-qa-selector="squash_checkbox" + data-testid="squash-checkbox" :title="tooltipTitle" @change="(checked) => $emit('input', checked)" > 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 index 9dd4e76befe..5b7657f15d9 100644 --- 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 @@ -1,12 +1,18 @@ <script> -import { GlButton, GlDropdown, GlDropdownItem, GlTooltipDirective } from '@gitlab/ui'; -import { sprintf, __ } from '~/locale'; +import { + GlButton, + GlDisclosureDropdown, + GlIcon, + GlLoadingIcon, + GlTooltipDirective, +} from '@gitlab/ui'; export default { components: { GlButton, - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, + GlIcon, + GlLoadingIcon, }, directives: { GlTooltip: GlTooltipDirective, @@ -29,10 +35,22 @@ export default { }; }, computed: { - dropdownLabel() { - if (!this.widget) return undefined; - - return sprintf(__('%{widget} options'), { widget: this.widget }); + dropdownItems() { + return this.tertiaryButtons.map((button) => { + return { + text: button.text, + href: button.href, + action: () => this.onClickAction(button), + icon: button.icon || button.iconName, + loading: button.loading, + extraAttrs: { + dataClipboardText: button.dataClipboardText, + dataMethod: button.dataMethod, + target: button.target, + disabled: button.disabled, + }, + }; + }); }, }, methods: { @@ -62,44 +80,31 @@ export default { 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-gl-tooltip - :title="__('Options')" - :text="dropdownLabel" + <gl-disclosure-dropdown + :items="dropdownItems" icon="ellipsis_v" no-caret category="tertiary" - right - lazy + placement="right" 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 #list-item="{ item }"> + <span class="gl-display-flex gl-align-items-center gl-justify-content-space-between"> + {{ item.text }} + <gl-loading-icon v-if="item.loading" size="sm" /> + <gl-icon v-else-if="item.icon" :name="item.icon" /> + </span> + </template> + </gl-disclosure-dropdown> <gl-button v-for="(btn, index) in tertiaryButtons" :id="btn.id" @@ -110,9 +115,8 @@ export default { :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" + :icon="btn.icon || btn.iconName" :data-testid="btn.testId || 'extension-actions-button'" :variant="btn.variant || 'confirm'" :loading="btn.loading" diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js index e8b97098a2b..5e9b72e13cf 100644 --- a/app/assets/javascripts/vue_merge_request_widget/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -29,6 +29,8 @@ export default () => { gl.mrWidgetData.gitlabLogo = gon.gitlab_logo; gl.mrWidgetData.defaultAvatarUrl = gon.default_avatar_url; + const dismissalDescriptions = JSON.parse(gl.mrWidgetData.dismissal_descriptions || '{}'); + // This is a false violation of @gitlab/no-runtime-template-compiler, since it // creates a new Vue instance by spreading a _valid_ Vue component definition // into the Vue constructor. @@ -43,6 +45,8 @@ export default () => { canCreatePipelineInTargetProject: parseBoolean( gl.mrWidgetData.can_create_pipeline_in_target_project, ), + commitPathTemplate: gl.mrWidgetData.commit_path_template, + dismissalDescriptions, }, ...MrWidgetOptions, apolloProvider, 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 175a0b0563f..02d73cf9cbd 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 @@ -153,10 +153,6 @@ export default { }, }, mixins: [mergeRequestQueryVariablesMixin], - provide: { - expandDetailsTooltip: __('Expand merge details'), - collapseDetailsTooltip: __('Collapse merge details'), - }, props: { mrData: { type: Object, @@ -576,7 +572,7 @@ export default { </mr-widget-alert-message> </div> - <div class="mr-widget-section" data-qa-selector="mr_widget_content"> + <div class="mr-widget-section" data-testid="mr-widget-content"> <component :is="componentName" :mr="mr" :service="service" /> <ready-to-merge v-if="mr.commitsCount" diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/merge_checks.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/merge_checks.query.graphql new file mode 100644 index 00000000000..6b602a0095c --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/queries/merge_checks.query.graphql @@ -0,0 +1,12 @@ +query mergeChecks($projectPath: ID!, $iid: String!) { + project(fullPath: $projectPath) { + id + mergeRequest(iid: $iid) { + id + userPermissions { + canMerge + } + mergeChecks @client + } + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql index faf21b28f86..a4c42070530 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql @@ -5,6 +5,9 @@ query workInProgress($projectPath: ID!, $iid: String!) { id shouldBeRebased sourceBranchProtected + userPermissions { + pushToSourceBranch + } } } } 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 bb74f82145f..a1b86c86979 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 @@ -2,7 +2,7 @@ import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_ke import { STATUS_CLOSED, STATUS_MERGED, STATUS_OPEN } from '~/issues/constants'; import { formatDate, getTimeago, timeagoLanguageCode } from '~/lib/utils/datetime_utility'; import { machine } from '~/lib/utils/finite_state_machine'; -import { badgeState } from '~/merge_requests/components/merge_request_status_badge.vue'; +import { badgeState } from '~/merge_requests/components/merge_request_header.vue'; import { MTWPS_MERGE_STRATEGY, MT_MERGE_STRATEGY, 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 8d2ef20b381..3855e4fc078 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 @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import updateAlertStatusMutation from '~/graphql_shared/mutations/alert_status_update.mutation.graphql'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; @@ -11,10 +11,10 @@ export default { 'AlertManagement|There was an error while updating the status of the alert.', ), UPDATE_ALERT_STATUS_INSTRUCTION: s__('AlertManagement|Please try again.'), + ASSIGN_STATUS_HEADER: s__('AlertManagement|Assign status'), }, components: { - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, }, inject: { trackAlertStatusUpdateOptions: { @@ -44,10 +44,20 @@ export default { default: () => PAGE_CONFIG.OPERATIONS.STATUSES, }, }, + data() { + return { + alertStatus: this.alert.status, + }; + }, computed: { dropdownClass() { - // eslint-disable-next-line no-nested-ternary - return this.isSidebar ? (this.isDropdownShowing ? 'show' : 'gl-display-none') : ''; + return this.isSidebar && !this.isDropdownShowing ? 'gl-display-none' : ''; + }, + items() { + return Object.entries(this.statuses).map(([value, text]) => ({ value, text })); + }, + headerText() { + return this.isSidebar ? this.$options.i18n.ASSIGN_STATUS_HEADER : ''; }, }, methods: { @@ -97,30 +107,15 @@ export default { <template> <div class="dropdown dropdown-menu-selectable" :class="dropdownClass"> - <gl-dropdown + <gl-collapsible-listbox ref="dropdown" - right - :text="statuses[alert.status]" - class="w-100" - toggle-class="dropdown-menu-toggle" - @keydown.esc.native="$emit('hide-dropdown')" - @hide="$emit('hide-dropdown')" - > - <p v-if="isSidebar" class="gl-dropdown-header-top" data-testid="dropdown-header"> - {{ s__('AlertManagement|Assign status') }} - </p> - <div class="dropdown-content dropdown-body"> - <gl-dropdown-item - v-for="(label, field) in statuses" - :key="field" - data-testid="statusDropdownItem" - :active="field === alert.status" - :active-class="'is-active'" - @click="updateAlertStatus(field)" - > - {{ label }} - </gl-dropdown-item> - </div> - </gl-dropdown> + v-model="alertStatus" + placement="right" + :header-text="headerText" + :items="items" + block + @hidden="$emit('hide-dropdown')" + @select="updateAlertStatus" + /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue index c512585b980..7b099516c5b 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue @@ -58,9 +58,10 @@ export default { }, toggleFormDropdown() { this.isDropdownShowing = !this.isDropdownShowing; - const { dropdown } = this.$refs.status.$refs.dropdown.$refs; + const { dropdown } = this.$refs.status.$refs; + if (dropdown && this.isDropdownShowing) { - dropdown.show(); + dropdown.open(); } }, handleUpdating(isMutationInProgress) { diff --git a/app/assets/javascripts/vue_shared/components/badges/beta_badge.vue b/app/assets/javascripts/vue_shared/components/badges/beta_badge.vue index e8d33b5538e..9cac176a06f 100644 --- a/app/assets/javascripts/vue_shared/components/badges/beta_badge.vue +++ b/app/assets/javascripts/vue_shared/components/badges/beta_badge.vue @@ -1,10 +1,10 @@ <script> -import { GlBadge, GlPopover } from '@gitlab/ui'; import { s__ } from '~/locale'; +import HoverBadge from './hover_badge.vue'; export default { name: 'BetaBadge', - components: { GlBadge, GlPopover }, + components: { HoverBadge }, i18n: { badgeLabel: s__('BetaBadge|Beta'), popoverTitle: s__("BetaBadge|What's Beta?"), @@ -41,27 +41,16 @@ export default { </script> <template> - <div> - <gl-badge ref="badge" href="#" :size="size" variant="neutral" class="gl-cursor-pointer">{{ - $options.i18n.badgeLabel - }}</gl-badge> - <gl-popover - triggers="hover focus click" - :show-close-button="true" - :target="target" - :title="$options.i18n.popoverTitle" - data-testid="beta-badge" - > - <p>{{ $options.i18n.descriptionParagraph }}</p> + <hover-badge :label="$options.i18n.badgeLabel" :size="size" :title="$options.i18n.popoverTitle"> + <p>{{ $options.i18n.descriptionParagraph }}</p> - <p class="gl-mb-0">{{ $options.i18n.listIntroduction }}</p> + <p class="gl-mb-0">{{ $options.i18n.listIntroduction }}</p> - <ul class="gl-pl-4"> - <li>{{ $options.i18n.listItemStability }}</li> - <li>{{ $options.i18n.listItemDataLoss }}</li> - <li>{{ $options.i18n.listItemReasonableEffort }}</li> - <li>{{ $options.i18n.listItemNearCompletion }}</li> - </ul> - </gl-popover> - </div> + <ul class="gl-pl-4"> + <li>{{ $options.i18n.listItemStability }}</li> + <li>{{ $options.i18n.listItemDataLoss }}</li> + <li>{{ $options.i18n.listItemReasonableEffort }}</li> + <li>{{ $options.i18n.listItemNearCompletion }}</li> + </ul> + </hover-badge> </template> diff --git a/app/assets/javascripts/vue_shared/components/badges/experiment_badge.stories.js b/app/assets/javascripts/vue_shared/components/badges/experiment_badge.stories.js new file mode 100644 index 00000000000..8e964c9bdf8 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/badges/experiment_badge.stories.js @@ -0,0 +1,24 @@ +import ExperimentBadge from './experiment_badge.vue'; + +export default { + component: ExperimentBadge, + title: 'vue_shared/experiment-badge', +}; + +const template = ` + <div style="height:600px;" class="gl-display-flex gl-justify-content-center gl-align-items-center"> + <experiment-badge :size="size" /> + </div> + `; + +const Template = (args, { argTypes }) => ({ + components: { ExperimentBadge }, + data() { + return { value: args.value }; + }, + props: Object.keys(argTypes), + template, +}); + +export const Default = Template.bind({}); +Default.args = {}; diff --git a/app/assets/javascripts/vue_shared/components/badges/experiment_badge.vue b/app/assets/javascripts/vue_shared/components/badges/experiment_badge.vue new file mode 100644 index 00000000000..26bae71ddb8 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/badges/experiment_badge.vue @@ -0,0 +1,43 @@ +<script> +import { s__ } from '~/locale'; +import HoverBadge from './hover_badge.vue'; + +export default { + name: 'ExperimentBadge', + components: { HoverBadge }, + i18n: { + badgeLabel: s__('ExperimentBadge|Experiment'), + popoverTitle: s__("ExperimentBadge|What's an Experiment?"), + descriptionParagraph: s__( + "ExperimentBadge|An Experiment is a feature that's in the process of being developed. It's not production-ready. We encourage users to try Experimental features and provide feedback.", + ), + listIntroduction: s__('ExperimentBadge|An Experiment:'), + listItemStability: s__('ExperimentBadge|May be unstable.'), + listItemDataLoss: s__('ExperimentBadge|Can cause data loss.'), + listItemNoSupport: s__('ExperimentBadge|Has no support and might not be documented.'), + listItemCanBeRemoved: s__('ExperimentBadge|Can be removed at any time.'), + }, + props: { + size: { + type: String, + required: false, + default: 'md', + }, + }, +}; +</script> + +<template> + <hover-badge :label="$options.i18n.badgeLabel" :size="size" :title="$options.i18n.popoverTitle"> + <p>{{ $options.i18n.descriptionParagraph }}</p> + + <p class="gl-mb-0">{{ $options.i18n.listIntroduction }}</p> + + <ul class="gl-pl-4"> + <li>{{ $options.i18n.listItemStability }}</li> + <li>{{ $options.i18n.listItemDataLoss }}</li> + <li>{{ $options.i18n.listItemNoSupport }}</li> + <li>{{ $options.i18n.listItemCanBeRemoved }}</li> + </ul> + </hover-badge> +</template> diff --git a/app/assets/javascripts/vue_shared/components/badges/hover_badge.vue b/app/assets/javascripts/vue_shared/components/badges/hover_badge.vue new file mode 100644 index 00000000000..351c7bd9da0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/badges/hover_badge.vue @@ -0,0 +1,52 @@ +<script> +import { GlBadge, GlPopover } from '@gitlab/ui'; + +export default { + name: 'HoverBadge', + components: { GlBadge, GlPopover }, + props: { + label: { + type: String, + required: true, + }, + title: { + type: String, + required: true, + }, + size: { + type: String, + required: false, + default: 'md', + }, + }, + methods: { + target() { + /** + * BVPopover retrieves the target during the `beforeDestroy` hook to deregister attached + * events. Since during `beforeDestroy` refs are `undefined`, it throws a warning in the + * console because we're trying to access the `$el` property of `undefined`. Optional + * chaining is not working in templates, which is why the method is used. + * + * See more on https://gitlab.com/gitlab-org/gitlab/-/merge_requests/49628#note_464803276 + */ + return this.$refs.badge?.$el; + }, + }, +}; +</script> + +<template> + <div> + <gl-badge ref="badge" href="#" :size="size" variant="neutral" class="gl-cursor-pointer">{{ + label + }}</gl-badge> + <gl-popover + triggers="hover focus click" + :show-close-button="true" + :target="target" + :title="title" + > + <slot></slot> + </gl-popover> + </div> +</template> 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 27bdcc69120..b52752d7e2f 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 @@ -41,7 +41,6 @@ export default { mounted() { this.renderRemainingMarkup(); handleBlobRichViewer(this.$refs.content, this.type); - handleLocationHash(); }, methods: { optimizeMarkupRendering() { @@ -76,8 +75,7 @@ export default { * */ if (!this.isMarkup || !this.remainingContent.length) { - this.$emit(CONTENT_LOADED_EVENT); - this.isLoading = false; + this.onContentLoaded(); return; } @@ -89,11 +87,15 @@ export default { setTimeout(() => { fileContent.append(...content); if (nextChunkEnd < this.remainingContent.length) return; - this.$emit(CONTENT_LOADED_EVENT); - this.isLoading = false; + this.onContentLoaded(); }, i); } }, + onContentLoaded() { + this.$emit(CONTENT_LOADED_EVENT); + handleLocationHash(); + this.isLoading = false; + }, }, safeHtmlConfig: { ADD_TAGS: ['gl-emoji', 'copy-code'], diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue index 1f45b4c5c9d..abbeac0e098 100644 --- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -57,16 +57,29 @@ export default { return badgeSizeOptions[value] !== undefined; }, }, + showTooltip: { + type: Boolean, + required: false, + default: true, + }, + useLink: { + type: Boolean, + default: true, + required: false, + }, }, computed: { - isSmallBadgeSize() { - return this.size === badgeSizeOptions.sm; + isNotLargeBadgeSize() { + return this.size !== badgeSizeOptions.lg; }, title() { - return !this.showText ? this.status?.text : ''; + return this.showTooltip && !this.showText ? this.status?.text : ''; }, detailsPath() { // For now, this can either come from graphQL with camelCase or REST API in snake_case + if (!this.useLink) { + return null; + } return this.status.detailsPath || this.status.details_path; }, badgeStyles() { @@ -121,7 +134,7 @@ export default { <template> <gl-badge v-gl-tooltip - :class="{ 'gl-pl-2': isSmallBadgeSize }" + :class="{ 'gl-px-2': !showText && isNotLargeBadgeSize }" :title="title" :href="detailsPath" :size="size" diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.vue index fa7c5bc1978..066b761ac9b 100644 --- a/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.vue @@ -47,13 +47,13 @@ export default { v-if="sshLink" :label="$options.labels.ssh" :link="sshLink" - qa-selector="copy_ssh_url_button" + test-id="copy-ssh-url-button" /> <clone-dropdown-item v-if="httpLink" :label="httpLabel" :link="httpLink" - qa-selector="copy_http_url_button" + test-id="copy-http-url-button" /> </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown_item.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown_item.vue index 0e322ebc686..6980e19733a 100644 --- a/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown_item.vue +++ b/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown_item.vue @@ -27,7 +27,7 @@ export default { type: String, required: true, }, - qaSelector: { + testId: { type: String, required: true, }, @@ -45,7 +45,7 @@ export default { :title="$options.copyURLTooltip" :aria-label="$options.copyURLTooltip" :data-clipboard-text="link" - :data-qa-selector="qaSelector" + :data-testid="testId" icon="copy-to-clipboard" class="gl-display-inline-flex" /> diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue index b34a6b11092..1f5896204ee 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue @@ -144,7 +144,7 @@ export default { </slot> </template> <slot name="default"> - <gl-dropdown-form class="gl-relative gl-min-h-7" data-qa-selector="labels_dropdown_content"> + <gl-dropdown-form class="gl-relative gl-min-h-7" data-testid="labels-dropdown-content"> <gl-loading-icon v-if="isLoading" size="lg" 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 5a7382bcd7c..23de8dd5596 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 @@ -262,6 +262,7 @@ export default { {{ __('No matches found') }} </gl-dropdown-text> <gl-dropdown-text v-else-if="hasFetched">{{ __('No suggestions found') }}</gl-dropdown-text> + <slot name="footer"></slot> </template> </gl-filtered-search-token> </template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue index c294c23abfc..4601287b417 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue @@ -4,6 +4,8 @@ import { compact } from 'lodash'; import { createAlert } from '~/alert'; import { __ } from '~/locale'; +import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; +import usersAutocompleteQuery from '~/graphql_shared/queries/users_autocomplete.query.graphql'; import { OPTIONS_NONE_ANY } from '../constants'; import BaseToken from './base_token.vue'; @@ -41,6 +43,12 @@ export default { preloadedUsers() { return this.config.preloadedUsers || []; }, + namespace() { + return this.config.isProject ? WORKSPACE_PROJECT : WORKSPACE_GROUP; + }, + fetchUsersQuery() { + return this.config.fetchUsers ? this.config.fetchUsers : this.fetchUsersBySearchTerm; + }, }, methods: { getActiveUser(users, data) { @@ -49,11 +57,19 @@ export default { getAvatarUrl(user) { return user.avatarUrl || user.avatar_url; }, + fetchUsersBySearchTerm(search) { + return this.$apollo + .query({ + query: usersAutocompleteQuery, + variables: { fullPath: this.config.fullPath, search, isProject: this.config.isProject }, + }) + .then(({ data }) => data[this.namespace]?.autocompleteUsers); + }, fetchUsers(searchTerm) { this.loading = true; const fetchPromise = this.config.fetchPath ? this.config.fetchUsers(this.config.fetchPath, searchTerm) - : this.config.fetchUsers(searchTerm); + : this.fetchUsersQuery(searchTerm); fetchPromise .then((res) => { diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue index ebc6b2cd740..d97f1ae6135 100644 --- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue +++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue @@ -156,7 +156,7 @@ export default { <gl-form-input ref="input" :readonly="readonly" - :size="size" + :width="size" class="gl-font-monospace! gl-cursor-default!" v-bind="formInputGroupProps" :value="value" @@ -183,7 +183,7 @@ export default { v-if="showCopyButton" :text="value" :title="copyButtonTitle" - data-qa-selector="clipboard_button" + data-testid="clipboard-button" @click="handleCopyButtonClick" /> </template> diff --git a/app/assets/javascripts/vue_shared/components/incubation/pagination.vue b/app/assets/javascripts/vue_shared/components/incubation/pagination.vue index b5afe92316a..6b70e9f3ed9 100644 --- a/app/assets/javascripts/vue_shared/components/incubation/pagination.vue +++ b/app/assets/javascripts/vue_shared/components/incubation/pagination.vue @@ -57,6 +57,7 @@ export default { :next-text="$options.i18n.nextPageButtonLabel" :prev-button-link="previousPageLink" :next-button-link="nextPageLink" + class="gl-mt-4" /> </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 05ce007e615..4ebd8861a67 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue @@ -60,7 +60,7 @@ export default { <template> <gl-disclosure-dropdown - data-qa-selector="apply_suggestion_dropdown" + data-testid="apply-suggestion-dropdown" fluid-width placement="right" size="small" @@ -81,7 +81,7 @@ export default { class="apply-suggestions-input-min-width" :placeholder="defaultCommitMessage" submit-on-enter - data-qa-selector="commit_message_field" + data-testid="commit-message-field" @submit="onApply" /> @@ -93,7 +93,7 @@ export default { class="gl-w-auto! gl-mt-3 gl-align-self-end" category="primary" variant="confirm" - data-qa-selector="commit_with_custom_message_button" + data-testid="commit-with-custom-message-button" @click="onApply" > {{ __('Apply') }} diff --git a/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue index f7f5ccdbf31..d99b90fa561 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue @@ -64,8 +64,8 @@ export default { const savedReply = this.savedReplies.find((r) => r.id === id); if (savedReply) { this.$emit('select', savedReply.content); - this.track_event(TRACKING_SAVED_REPLIES_USE); - this.track_event( + this.trackEvent(TRACKING_SAVED_REPLIES_USE); + this.trackEvent( isInMr ? TRACKING_SAVED_REPLIES_USE_IN_MR : TRACKING_SAVED_REPLIES_USE_IN_OTHER, ); } diff --git a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_switcher.vue b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_switcher.vue index 2426a917a53..1327436a9b4 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_switcher.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_switcher.vue @@ -1,16 +1,10 @@ <script> -import { GlButton, GlPopover, GlLink } from '@gitlab/ui'; -import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; +import { GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; -import RICH_TEXT_EDITOR_ILLUSTRATION from '../../../../images/callouts/rich_text_editor_illustration.svg?url'; -import { counter } from './utils'; export default { components: { GlButton, - GlLink, - GlPopover, - UserCalloutDismisser, }, props: { value: { @@ -18,15 +12,7 @@ export default { required: true, }, }, - data() { - return { - counter: counter(), - }; - }, computed: { - showPromoPopover() { - return this.markdownEditorSelected && this.counter === 0; - }, markdownEditorSelected() { return this.value === 'markdown'; }, @@ -36,84 +22,19 @@ export default { : __('Switch to plain text editing'); }, }, - methods: { - switchEditorType(insertTemplate = false) { - this.$emit('switch', insertTemplate); - }, - }, richTextEditorButtonId: 'switch-to-rich-text-editor', - RICH_TEXT_EDITOR_ILLUSTRATION, }; </script> <template> <div class="content-editor-switcher gl-display-inline-flex gl-align-items-center"> - <user-callout-dismisser feature-name="rich_text_editor"> - <template #default="{ dismiss, shouldShowCallout }"> - <div> - <gl-popover - :target="$options.richTextEditorButtonId" - :show="Boolean(showPromoPopover && shouldShowCallout)" - show-close-button - :css-classes="['rich-text-promo-popover gl-p-2']" - triggers="manual" - data-testid="rich-text-promo-popover" - @close-button-clicked="dismiss" - > - <img - :src="$options.RICH_TEXT_EDITOR_ILLUSTRATION" - :alt="''" - class="rich-text-promo-popover-illustration" - width="280" - height="130" - /> - <h5 class="gl-mt-3 gl-mb-3">{{ __('Writing just got easier') }}</h5> - <p class="gl-m-0"> - {{ - __( - 'Use the new rich text editor to see your text and tables fully formatted as you type. No need to remember any formatting syntax, or switch between preview and editing modes!', - ) - }} - </p> - <gl-link - class="gl-button btn btn-confirm block gl-mb-2 gl-mt-4" - variant="confirm" - category="primary" - target="_blank" - block - @click=" - switchEditorType(showPromoPopover); - dismiss(); - " - > - {{ __('Try the rich text editor now') }} - </gl-link> - </gl-popover> - <gl-button - :id="$options.richTextEditorButtonId" - class="btn btn-default btn-sm gl-button btn-default-tertiary gl-font-sm! gl-text-secondary! gl-px-4!" - data-qa-selector="editing_mode_switcher" - @click=" - switchEditorType(); - dismiss(); - " - >{{ text }}</gl-button - > - </div> - </template> - </user-callout-dismisser> + <gl-button + :id="$options.richTextEditorButtonId" + size="small" + category="tertiary" + class="gl-font-sm! gl-text-secondary! gl-px-4!" + data-testid="editing-mode-switcher" + @click="$emit('switch')" + >{{ text }}</gl-button + > </div> </template> -<style> -.rich-text-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; -} - -.rich-text-promo-popover-illustration { - width: calc(100% + 32px); - margin: -32px -16px 0; -} -</style> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index a26f8f71601..24211833026 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -12,6 +12,8 @@ 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 { MARKDOWN_EDITOR_READY_EVENT } from '~/vue_shared/constants'; +import markdownEditorEventHub from '~/vue_shared/components/markdown/eventhub'; import MarkdownHeader from './header.vue'; import MarkdownToolbar from './toolbar.vue'; @@ -259,7 +261,8 @@ export default { }, mounted() { // GLForm class handles all the toolbar buttons - return new GLForm( + // eslint-disable-next-line no-new + new GLForm( $(this.$refs['gl-form']), { emojis: this.enableAutocomplete, @@ -276,6 +279,8 @@ export default { true, this.autocompleteDataSources, ); + + markdownEditorEventHub.$emit(MARKDOWN_EDITOR_READY_EVENT); }, beforeDestroy() { const glForm = $(this.$refs['gl-form']).data('glForm'); diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 286a1b87ad0..741bdfd211b 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -263,7 +263,7 @@ export default { <gl-button v-if="enablePreview" data-testid="preview-toggle" - value="preview" + :value="previewMarkdown ? 'preview' : 'edit'" :label="$options.i18n.previewTabTitle" class="js-md-preview-button gl-flex-direction-row-reverse gl-align-items-center gl-font-weight-normal! gl-mr-2" size="small" @@ -281,7 +281,7 @@ export default { :tag-content="lineContent" tracking-property="codeSuggestion" icon="doc-code" - data-qa-selector="suggestion_button" + data-testid="suggestion-button" class="js-suggestion-btn" @click="handleSuggestDismissed" /> @@ -305,7 +305,7 @@ export default { variant="confirm" category="primary" size="small" - data-qa-selector="dismiss_suggestion_popover_button" + data-testid="dismiss-suggestion-popover-button" @click="handleSuggestDismissed" > {{ __('Got it') }} 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 fc7e0a7c732..4a3c3cf0053 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -248,6 +248,13 @@ export default { }); } }, + onKeydown(event) { + const isModifierKey = event.ctrlKey || event.metaKey; + if (isModifierKey && event.key === 'k') { + event.preventDefault(); + } + this.$emit('keydown', event); + }, }, EDITING_MODE_KEY, }; @@ -292,7 +299,7 @@ export default { class="note-textarea js-gfm-input markdown-area" dir="auto" :data-supports-quick-actions="supportsQuickActions" - :data-qa-selector="formFieldProps['data-qa-selector'] || 'markdown_editor_form_field'" + :data-testid="formFieldProps['data-testid'] || 'markdown-editor-form-field'" :disabled="disabled" @input="updateMarkdownFromMarkdownField" @keydown="$emit('keydown', $event)" @@ -317,13 +324,13 @@ export default { :code-suggestions-config="codeSuggestionsConfig" @initialized="setEditorAsAutofocused" @change="updateMarkdownFromContentEditor" - @keydown="$emit('keydown', $event)" + @keydown="onKeydown" @enableMarkdownEditor="onEditingModeChange('markdownField')" /> <input v-bind="formFieldProps" :value="markdown" - data-qa-selector="markdown_editor_form_field" + data-testid="markdown-editor-form-field" type="hidden" /> </div> diff --git a/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js index 6c2f084591e..f7fb1339bbc 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js +++ b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js @@ -105,7 +105,6 @@ export function mountMarkdownEditor(options = {}) { return h(MarkdownEditor, { props: { setFacade, - enableContentEditor: Boolean(gon.features?.contentEditorOnIssues), value: formFieldValue, renderMarkdownPath, markdownDocsPath, diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue index 8a0ca8ebac1..a822e2a6151 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -144,13 +144,13 @@ export default { <gl-icon name="question-o" css-classes="link-highlight" /> </a> </div> - <gl-badge v-if="isApplied" variant="success" data-qa-selector="applied_badge"> + <gl-badge v-if="isApplied" variant="success" data-testid="applied-badge"> {{ __('Applied') }} </gl-badge> <div v-else-if="isApplying" class="gl-display-flex gl-align-items-center text-secondary" - data-qa-selector="applying_badge" + data-testid="applying-badge" > <gl-loading-icon size="sm" class="gl-align-items-center gl-justify-content-center gl-mr-3" /> <span>{{ applyingSuggestionsMessage }}</span> @@ -169,7 +169,7 @@ export default { <div v-else-if="!isDisableButton && suggestionsCount > 1"> <gl-button class="btn-inverted js-add-to-batch-btn btn-grouped" - data-qa-selector="add_suggestion_batch_button" + data-testid="add-suggestion-batch-button" :disabled="isDisableButton" size="small" @click="addSuggestionToBatch" diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index a4516fae73d..c0c8c4735e7 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -2,8 +2,6 @@ <script> import { GlButton, GlLoadingIcon, GlSprintf, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { updateText } from '~/lib/utils/text_markdown'; -import { __, sprintf } from '~/locale'; -import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; import EditorModeSwitcher from './editor_mode_switcher.vue'; export default { @@ -56,23 +54,6 @@ export default { }); } }, - handleEditorModeChanged(isFirstSwitch) { - if (isFirstSwitch) { - this.insertIntoTextarea( - __(`### Rich text editor`), - '', - sprintf( - __( - 'Try out **styling** _your_ content right here or read the [direction](%{directionUrl}).', - ), - { - directionUrl: `${PROMO_URL}/direction/plan/knowledge/content_editor/`, - }, - ), - ); - } - this.$emit('enableContentEditor'); - }, }, }; </script> @@ -91,7 +72,7 @@ export default { v-if="showEditorModeSwitcher" size="small" value="markdown" - @switch="handleEditorModeChanged" + @switch="$emit('enableContentEditor')" /> <div class="gl-display-flex"> <div v-if="canAttachFile" class="uploading-container gl-font-sm gl-line-height-32 gl-mr-3"> @@ -152,6 +133,7 @@ export default { category="tertiary" size="small" :title="__('Markdown is supported')" + :aria-label="__('Markdown is supported')" class="gl-px-3!" /> </div> diff --git a/app/assets/javascripts/vue_shared/components/markdown/utils.js b/app/assets/javascripts/vue_shared/components/markdown/utils.js deleted file mode 100644 index 0227d5a0fbc..00000000000 --- a/app/assets/javascripts/vue_shared/components/markdown/utils.js +++ /dev/null @@ -1,7 +0,0 @@ -let i = 0; - -export const counter = () => { - const n = i; - i += 1; - return n; -}; diff --git a/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue b/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue index 7871721f38b..5c6766bbe45 100644 --- a/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue @@ -19,7 +19,6 @@ import MergeRequest from '~/merge_request'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; -import NewHeaderActionsPopover from '~/issues/show/components/new_header_actions_popover.vue'; import { TYPE_MERGE_REQUEST } from '~/issues/constants'; Vue.use(VueApollo); @@ -50,7 +49,6 @@ export default { GlDisclosureDropdownGroup, SidebarSubscriptionsWidget, AbuseCategorySelector, - NewHeaderActionsPopover, SummaryNotesToggle: () => import('ee_component/merge_requests/components/summary_notes_toggle.vue'), }, @@ -143,6 +141,9 @@ export default { isMovedMrSidebar() { return this.glFeatures.movedMrSidebar; }, + isNotificationsTodosButtons() { + return this.glFeatures.notificationsTodosButtons && this.glFeatures.movedMrSidebar; + }, draftLabel() { return this.draft ? this.$options.i18n.markAsReady : this.$options.i18n.markAsDraft; }, @@ -250,7 +251,9 @@ export default { /> </div> </template> - <gl-disclosure-dropdown-group v-if="isLoggedIn && isMovedMrSidebar"> + <gl-disclosure-dropdown-group + v-if="isLoggedIn && isMovedMrSidebar && !isNotificationsTodosButtons" + > <sidebar-subscriptions-widget :iid="String(mr.iid)" :full-path="fullPath" @@ -261,7 +264,10 @@ export default { <gl-disclosure-dropdown-group bordered - :class="{ 'gl-mt-0! gl-pt-0! gl-border-t-0!': !(isLoggedIn && isMovedMrSidebar) }" + :class="{ + 'gl-mt-0! gl-pt-0! gl-border-t-0!': + !(isLoggedIn && isMovedMrSidebar) || isNotificationsTodosButtons, + }" > <gl-disclosure-dropdown-item v-if="canUpdateMergeRequest" @@ -358,8 +364,6 @@ export default { </gl-disclosure-dropdown-group> </gl-disclosure-dropdown> - <new-header-actions-popover v-if="isMovedMrSidebar" :issue-type="issuableType" /> - <abuse-category-selector v-if="!isCurrentUser && isReportAbuseDrawerOpen" :reported-user-id="reportedUserId" diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue index fac32bfdb24..cb9b85b9ef3 100644 --- a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue @@ -12,7 +12,7 @@ export default { </script> <template> - <timeline-entry-item class="note note-wrapper" data-qa-selector="skeleton_note_placeholder"> + <timeline-entry-item class="note note-wrapper"> <div class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600" ></div> diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_cli_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_cli_instructions.vue index 36e608a068b..f59664e8d1d 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_cli_instructions.vue +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_cli_instructions.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlCollapsibleListbox, GlLoadingIcon } from '@gitlab/ui'; import { s__ } from '~/locale'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; import { REGISTRATION_TOKEN_PLACEHOLDER } from '../constants'; @@ -8,8 +8,7 @@ import getRunnerSetupInstructionsQuery from '../graphql/get_runner_setup.query.g export default { components: { GlButton, - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, GlLoadingIcon, ModalCopyButton, }, @@ -27,7 +26,7 @@ export default { }, data() { return { - selectedArchitecture: this.platform?.architectures[0] || null, + selectedArchName: this.platform?.architectures[0]?.name || null, instructions: null, }; }, @@ -55,6 +54,9 @@ export default { architectures() { return this.platform?.architectures || []; }, + selectedArchitecture() { + return this.architectures.find(({ name }) => name === this.selectedArchName) || null; + }, binaryUrl() { return this.selectedArchitecture?.downloadLocation; }, @@ -69,20 +71,22 @@ export default { } return registerInstructions; }, + listboxItems() { + return this.architectures.map(({ name }) => { + return { text: name, value: name }; + }); + }, }, watch: { platform() { // reset selection if architecture is not in this list - const arch = this.architectures.find(({ name }) => name === this.selectedArchitecture.name); + const arch = this.architectures.find(({ name }) => name === this.selectedArchName); if (!arch) { - this.selectArchitecture(this.architectures[0]); + this.selectedArchName = this.architectures[0]?.name || null; } }, }, methods: { - selectArchitecture(architecture) { - this.selectedArchitecture = architecture; - }, onClose() { this.$emit('close'); }, @@ -104,18 +108,7 @@ export default { <gl-loading-icon v-if="$apollo.loading" size="sm" inline /> </h5> - <gl-dropdown class="gl-mb-3" :text="selectedArchitecture.name"> - <gl-dropdown-item - v-for="architecture in architectures" - :key="architecture.name" - is-check-item - :is-checked="selectedArchitecture.name === architecture.name" - data-testid="architecture-dropdown-item" - @click="selectArchitecture(architecture)" - > - {{ architecture.name }} - </gl-dropdown-item> - </gl-dropdown> + <gl-collapsible-listbox v-model="selectedArchName" class="gl-mb-3" :items="listboxItems" /> <div class="gl-sm-display-flex gl-align-items-center gl-mb-3"> <h5>{{ $options.i18n.downloadInstallBinary }}</h5> <gl-button diff --git a/app/assets/javascripts/vue_shared/components/segmented_control_button_group.vue b/app/assets/javascripts/vue_shared/components/segmented_control_button_group.vue index f50706b6de8..e0e8200580a 100644 --- a/app/assets/javascripts/vue_shared/components/segmented_control_button_group.vue +++ b/app/assets/javascripts/vue_shared/components/segmented_control_button_group.vue @@ -1,6 +1,21 @@ <script> import { GlButtonGroup, GlButton } from '@gitlab/ui'; +const validateOptionsProp = (options) => { + const requiredOptionPropType = { + value: ['string', 'number', 'boolean'], + disabled: ['boolean', 'undefined'], + }; + const optionProps = Object.keys(requiredOptionPropType); + + return options.every((option) => { + if (!option) { + return false; + } + return optionProps.every((name) => requiredOptionPropType[name].includes(typeof option[name])); + }); +}; + // TODO: We're planning to move this component to GitLab UI // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1787 export default { @@ -12,6 +27,7 @@ export default { options: { type: Array, required: true, + validator: validateOptionsProp, }, value: { type: [String, Number, Boolean], diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/blame_info.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/blame_info.vue new file mode 100644 index 00000000000..9bce9402afa --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/blame_info.vue @@ -0,0 +1,51 @@ +<script> +import { GlTooltipDirective } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import CommitInfo from '~/repository/components/commit_info.vue'; +import { calculateBlameOffset, toggleBlameClasses } from '../utils'; + +export default { + name: 'BlameInfo', + components: { + CommitInfo, + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml, + }, + props: { + blameData: { + type: Array, + required: true, + }, + }, + computed: { + blameInfo() { + return this.blameData.map((blame, index) => ({ + ...blame, + blameOffset: calculateBlameOffset(blame.lineno, index), + })); + }, + }, + mounted() { + toggleBlameClasses(this.blameData, true); + }, + destroyed() { + toggleBlameClasses(this.blameData, false); + }, +}; +</script> +<template> + <div class="blame gl-bg-gray-10"> + <div class="blame-commit gl-border-none!"> + <commit-info + v-for="(blame, index) in blameInfo" + :key="index" + :class="{ 'gl-border-t': index !== 0 }" + class="gl-display-flex gl-absolute gl-px-3" + :style="{ top: blame.blameOffset }" + :commit="blame.commit" + /> + </div> + </div> +</template> 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 797a38d8171..4d5d877d43b 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 @@ -258,7 +258,7 @@ export default { :class="$options.userColorScheme" data-type="simple" :data-path="blob.path" - data-qa-selector="blob_viewer_file_content" + data-testid="blob-viewer-file-content" > <codeowners-validation v-if="isCodeownersFile" diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js new file mode 100644 index 00000000000..af01653fc0d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js @@ -0,0 +1,37 @@ +const BLAME_INFO_CLASSLIST = ['gl-border-t', 'gl-border-gray-500', 'gl-pt-3!']; +const PADDING_BOTTOM_LARGE = 'gl-pb-6!'; +const PADDING_BOTTOM_SMALL = 'gl-pb-3!'; + +const findLineNumberElement = (lineNumber) => document.getElementById(`L${lineNumber}`); + +const findLineContentElement = (lineNumber) => document.getElementById(`LC${lineNumber}`); + +export const calculateBlameOffset = (lineNumber) => { + if (lineNumber === 1) return '0px'; + const lineContentOffset = findLineContentElement(lineNumber)?.offsetTop; + return `${lineContentOffset}px`; +}; + +export const toggleBlameClasses = (blameData, isVisible) => { + /** + * Adds/removes classes to line number/content elements to match the line with the blame info + * */ + const method = isVisible ? 'add' : 'remove'; + blameData.forEach(({ lineno, span }) => { + const lineNumberEl = findLineNumberElement(lineno)?.parentElement; + const lineContentEl = findLineContentElement(lineno); + const lineNumberSpanEl = findLineNumberElement(lineno + span - 1)?.parentElement; + const lineContentSpanEl = findLineContentElement(lineno + span - 1); + + lineNumberEl?.classList[method](...BLAME_INFO_CLASSLIST); + lineContentEl?.classList[method](...BLAME_INFO_CLASSLIST); + + if (span === 1) { + lineNumberSpanEl?.classList[method](PADDING_BOTTOM_LARGE); + lineContentSpanEl?.classList[method](PADDING_BOTTOM_LARGE); + } else { + lineNumberSpanEl?.classList[method](PADDING_BOTTOM_SMALL); + lineContentSpanEl?.classList[method](PADDING_BOTTOM_SMALL); + } + }); +}; diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue index 7c9a1bcd8cc..058a00e169a 100644 --- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue +++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue @@ -1,5 +1,5 @@ <script> -import { GlTooltipDirective } from '@gitlab/ui'; +import { GlTruncate, GlTooltipDirective } from '@gitlab/ui'; import { DATE_TIME_FORMATS, DEFAULT_DATE_TIME_FORMAT } from '~/lib/utils/datetime_utility'; import timeagoMixin from '../mixins/timeago'; @@ -12,6 +12,9 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + components: { + GlTruncate, + }, mixins: [timeagoMixin], props: { time: { @@ -34,11 +37,19 @@ export default { default: DEFAULT_DATE_TIME_FORMAT, validator: (timeFormat) => DATE_TIME_FORMATS.includes(timeFormat), }, + enableTruncation: { + type: Boolean, + required: false, + default: false, + }, }, computed: { timeAgo() { return this.timeFormatted(this.time, this.dateTimeFormat); }, + tooltipText() { + return this.enableTruncation ? undefined : this.tooltipTitle(this.time); + }, }, }; </script> @@ -46,8 +57,11 @@ export default { <time v-gl-tooltip.viewport="{ placement: tooltipPlacement }" :class="cssClass" - :title="tooltipTitle(time)" + :title="tooltipText" :datetime="time" - ><slot :time-ago="timeAgo">{{ timeAgo }}</slot></time + ><slot :time-ago="timeAgo" + ><template v-if="enableTruncation"><gl-truncate :text="timeAgo" with-tooltip /></template + ><template v-else>{{ timeAgo }}</template></slot + ></time > </template> diff --git a/app/assets/javascripts/vue_shared/components/toggle_labels.vue b/app/assets/javascripts/vue_shared/components/toggle_labels.vue new file mode 100644 index 00000000000..05c837e32f0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/toggle_labels.vue @@ -0,0 +1,62 @@ +<script> +import { GlToggle } from '@gitlab/ui'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import isShowingLabelsQuery from '~/graphql_shared/client/is_showing_labels.query.graphql'; +import setIsShowingLabelsMutation from '~/graphql_shared/client/set_is_showing_labels.mutation.graphql'; + +export default { + components: { + GlToggle, + LocalStorageSync, + }, + data() { + return { + isShowingLabels: null, + }; + }, + apollo: { + isShowingLabels: { + query: isShowingLabelsQuery, + update: (data) => data.isShowingLabels, + }, + }, + computed: { + trackProperty() { + return this.isShowingLabels ? 'on' : 'off'; + }, + }, + methods: { + setShowLabels(val) { + this.$apollo.mutate({ + mutation: setIsShowingLabelsMutation, + variables: { + isShowingLabels: val, + }, + }); + }, + }, +}; +</script> + +<template> + <div class="board-labels-toggle-wrapper gl-display-flex gl-align-items-center gl-ml-3 gl-h-7"> + <local-storage-sync + :value="isShowingLabels" + storage-key="gl-show-board-labels" + @input="setShowLabels" + /> + <gl-toggle + :value="isShowingLabels" + :label="__('Show labels')" + :data-track-property="trackProperty" + data-track-action="toggle" + data-track-label="show_labels" + label-position="left" + aria-describedby="board-labels-toggle-text" + data-testid="show-labels-toggle" + data-qa-selector="show_labels_toggle" + class="gl-flex-direction-row" + @change="setShowLabels" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue index 9665e188469..46496d2e483 100644 --- a/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue +++ b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue @@ -2,12 +2,7 @@ export default { provide() { return { - // We can't use this.vuexModule due to bug in vue-apollo when - // provide is called in beforeCreate - // See https://github.com/vuejs/vue-apollo/pull/1153 for details - - // @vue-compat does not care to normalize propsData fields - vuexModule: this.$options.propsData.vuexModule ?? this.$options.propsData['vuex-module'], + vuexModule: this.vuexModule, }; }, 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 beb8321a271..9fb0add5522 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -355,7 +355,7 @@ export default { <span data-testid="action-primary-text" class="gl-font-weight-bold gl-mb-2">{{ action.text }}</span> - <span data-testid="action-secondary-text" class="gl-text-gray-700"> + <span data-testid="action-secondary-text" class="gl-font-sm gl-text-secondary"> {{ action.secondaryText }} </span> </div> diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js index 9c001fa2e9a..81e75c4e1d5 100644 --- a/app/assets/javascripts/vue_shared/constants.js +++ b/app/assets/javascripts/vue_shared/constants.js @@ -97,4 +97,7 @@ export const confidentialityInfoText = (workspaceType, issuableType) => export const EDITING_MODE_KEY = 'gl-markdown-editor-mode'; export const EDITING_MODE_MARKDOWN_FIELD = 'markdownField'; export const EDITING_MODE_CONTENT_EDITOR = 'contentEditor'; + export const CLEAR_AUTOSAVE_ENTRY_EVENT = 'markdown_clear_autosave_entry'; +export const CONTENT_EDITOR_READY_EVENT = 'content_editor_ready'; +export const MARKDOWN_EDITOR_READY_EVENT = 'markdown_editor_ready'; diff --git a/app/assets/javascripts/vue_shared/global_search/constants.js b/app/assets/javascripts/vue_shared/global_search/constants.js index 43110c0c9af..14ea0389bad 100644 --- a/app/assets/javascripts/vue_shared/global_search/constants.js +++ b/app/assets/javascripts/vue_shared/global_search/constants.js @@ -8,6 +8,7 @@ export const ALL_GITLAB = __('All GitLab'); export const SEARCH_GITLAB = s__('GlobalSearch|Search GitLab'); export const PLACES = s__('GlobalSearch|Places'); +export const COMMAND_PALETTE = s__('GlobalSearch|Command palette'); export const SEARCH_DESCRIBED_BY_DEFAULT = s__( 'GlobalSearch|%{count} default results provided. Use the up and down arrow keys to navigate search results list.', ); diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue index 033bb8c3885..679332163b5 100644 --- a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue @@ -22,6 +22,10 @@ export default { type: String, required: true, }, + issuableType: { + type: String, + required: true, + }, }, }; </script> @@ -34,6 +38,7 @@ export default { :description-help-path="descriptionHelpPath" :labels-fetch-path="labelsFetchPath" :labels-manage-path="labelsManagePath" + :issuable-type="issuableType" > <template #actions="issuableMeta"> <slot name="actions" v-bind="issuableMeta"></slot> 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 1cfa3f6d3d7..64f0ec3fbc7 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,15 +1,17 @@ <script> -import { GlForm, GlFormInput, GlFormGroup } from '@gitlab/ui'; +import { GlForm, GlFormInput, GlFormCheckbox, GlFormGroup } from '@gitlab/ui'; import LabelsSelect from '~/sidebar/components/labels/labels_select_vue/labels_select_root.vue'; import { VARIANT_EMBEDDED } from '~/sidebar/components/labels/labels_select_widget/constants'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; +import { issuableTypeText } from '~/issues/constants'; export default { VARIANT_EMBEDDED, components: { GlForm, GlFormInput, + GlFormCheckbox, GlFormGroup, MarkdownEditor, LabelsSelect, @@ -31,6 +33,10 @@ export default { type: String, required: true, }, + issuableType: { + type: String, + required: true, + }, }, descriptionFormFieldProps: { ariaLabel: __('Description'), @@ -44,10 +50,20 @@ export default { return { issuableTitle: '', issuableDescription: '', + issuableConfidential: false, selectedLabels: [], }; }, - computed: {}, + computed: { + confidentialityText() { + return sprintf( + __( + 'This %{issuableType} is confidential and should only be visible to team members with at least Reporter access.', + ), + { issuableType: issuableTypeText[this.issuableType] }, + ); + }, + }, methods: { handleUpdateSelectedLabels(labels) { if (labels.length) { @@ -85,6 +101,15 @@ export default { /> </div> </div> + <div data-testid="issuable-confidential" class="form-group row"> + <div class="col-12"> + <gl-form-group :label="__('Confidentiality')" label-for="issuable-confidential"> + <gl-form-checkbox id="issuable-confidential" v-model="issuableConfidential"> + {{ confidentialityText }} + </gl-form-checkbox> + </gl-form-group> + </div> + </div> <div data-testid="issuable-labels" class="form-group row"> <label for="issuable-labels" class="col-12">{{ __('Labels') }}</label> <div class="col-12"> @@ -111,6 +136,7 @@ export default { name="actions" :issuable-title="issuableTitle" :issuable-description="issuableDescription" + :issuable-confidential="issuableConfidential" :selected-labels="selectedLabels" ></slot> </div> 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 690d9523a63..bb36df0a778 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 @@ -8,7 +8,6 @@ import { isExternal, setUrlFragment } from '~/lib/utils/url_utility'; import { __, n__, sprintf } from '~/locale'; import IssuableAssignees from '~/issuable/components/issue_assignees.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; -import SafeHtml from '~/vue_shared/directives/safe_html'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import { STATE_CLOSED } from '~/work_items/constants'; import { isAssigneesWidget, isLabelsWidget } from '~/work_items/utils'; @@ -25,7 +24,6 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml, }, mixins: [timeagoMixin], props: { @@ -91,9 +89,6 @@ export default { authorId() { return getIdFromGraphQLId(this.author.id); }, - isIssueTrackerExternal() { - return Boolean(this.issuable.externalTracker); - }, isIssuableUrlExternal() { return isExternal(this.webUrl ?? ''); }, @@ -266,36 +261,20 @@ export default { v-if="issuable.hidden" v-gl-tooltip name="spam" - :title="__('This issue is hidden because its author has been banned')" + :title="__('This issue is hidden because its author has been banned.')" :aria-label="__('Hidden')" /> - <template v-if="isIssueTrackerExternal"> - <gl-link - class="issue-title-text" - dir="auto" - :href="webUrl" - data-qa-selector="issuable_title_link" - data-testid="issuable-title-link" - v-bind="issuableTitleProps" - @click="handleIssuableItemClick" - > - {{ issuable.title }} - <gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" /> - </gl-link> - </template> - <template v-else> - <gl-link - v-safe-html="issuable.titleHtml || issuable.title" - class="issue-title-text" - dir="auto" - :href="webUrl" - data-qa-selector="issuable_title_link" - data-testid="issuable-title-link" - v-bind="issuableTitleProps" - @click="handleIssuableItemClick" - /> + <gl-link + class="issue-title-text" + dir="auto" + :href="webUrl" + data-testid="issuable-title-link" + v-bind="issuableTitleProps" + @click="handleIssuableItemClick" + > + {{ issuable.title }} <gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" /> - </template> + </gl-link> <span v-if="taskStatus" class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-2 gl-font-sm" diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue index c4b92454ac0..a9b5e3a66a8 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue @@ -1,6 +1,8 @@ <script> import { GlIcon, GlBadge, GlButton, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import HiddenBadge from '~/issuable/components/hidden_badge.vue'; +import LockedBadge from '~/issuable/components/locked_badge.vue'; import { issuableStatusText, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants'; import { isExternal } from '~/lib/utils/url_utility'; import { __, n__, sprintf } from '~/locale'; @@ -16,6 +18,8 @@ export default { GlButton, GlLink, GlSprintf, + HiddenBadge, + LockedBadge, TimeAgoTooltip, WorkItemTypeIcon, }, @@ -101,16 +105,6 @@ export default { ? 'success' : 'info'; }, - blockedTooltip() { - return sprintf(__('This %{issuable} is locked. Only project members can comment.'), { - issuable: this.issuableType, - }); - }, - hiddenTooltip() { - return sprintf(__('This %{issuable} is hidden because its author has been banned'), { - issuable: this.issuableType, - }); - }, shouldShowWorkItemTypeIcon() { return this.showWorkItemTypeIcon && this.issuableType; }, @@ -174,22 +168,8 @@ export default { :issuable-type="issuableType" :workspace-type="workspaceType" /> - <span v-if="blocked" class="issuable-warning-icon"> - <gl-icon - v-gl-tooltip.bottom - name="lock" - :title="blockedTooltip" - :aria-label="__('Blocked')" - /> - </span> - <span v-if="isHidden" class="issuable-warning-icon"> - <gl-icon - v-gl-tooltip.bottom - name="spam" - :title="hiddenTooltip" - :aria-label="__('Hidden')" - /> - </span> + <locked-badge v-if="blocked" :issuable-type="issuableType" /> + <hidden-badge v-if="isHidden" :issuable-type="issuableType" /> <work-item-type-icon v-if="shouldShowWorkItemTypeIcon" show-text 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 f54c4c52743..3412848a9b7 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 @@ -138,7 +138,10 @@ export default { </div> <template v-if="activePanel"> - <div class="gl-display-flex gl-align-items-center gl-py-5"> + <div + data-testid="active-panel-template" + class="gl-display-flex gl-align-items-center gl-py-5" + > <div class="col-auto"> <img aria-hidden :src="activePanel.imageSrc" /> </div> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue index 57faed61280..c867e53dc30 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue @@ -5,6 +5,7 @@ import { ASC } from '~/notes/constants'; import { __ } from '~/locale'; import { clearDraft } from '~/lib/utils/autosave'; import createNoteMutation from '../../graphql/notes/create_work_item_note.mutation.graphql'; +import groupWorkItemByIidQuery from '../../graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; import { TRACKING_CATEGORY_SHOW, i18n } from '../../constants'; import WorkItemNoteSignedOut from './work_item_note_signed_out.vue'; @@ -21,8 +22,12 @@ export default { WorkItemCommentForm, }, mixins: [Tracking.mixin()], - inject: ['fullPath'], + inject: ['isGroup'], props: { + fullPath: { + type: String, + required: true, + }, workItemId: { type: String, required: true, @@ -90,7 +95,9 @@ export default { }, apollo: { workItem: { - query: workItemByIidQuery, + query() { + return this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery; + }, variables() { return { fullPath: this.fullPath, @@ -109,6 +116,9 @@ export default { }, }, computed: { + isLoading() { + return this.$apollo.queries.workItem.loading; + }, signedIn() { return Boolean(window.gon.current_user_id); }, @@ -248,7 +258,7 @@ export default { <li :class="timelineEntryClass"> <work-item-note-signed-out v-if="!signedIn" /> <work-item-comment-locked - v-else-if="!canCreateNote" + v-else-if="!isLoading && !canCreateNote" :work-item-type="workItemType" :is-project-archived="isProjectArchived" /> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue index a79169bde1e..c7d8a50f402 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue @@ -35,7 +35,6 @@ export default { GlTooltip: GlTooltipDirective, }, mixins: [Tracking.mixin()], - inject: ['fullPath'], props: { workItemId: { type: String, diff --git a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue index fd8842aa01a..fed21a1c277 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue @@ -18,8 +18,11 @@ export default { DiscussionNotesRepliesWrapper, WorkItemNoteReplying, }, - inject: ['fullPath'], props: { + fullPath: { + type: String, + required: true, + }, workItemId: { type: String, required: true, @@ -154,6 +157,7 @@ export default { :is-first-note="true" :note="note" :discussion-id="discussionId" + :full-path="fullPath" :has-replies="hasReplies" :work-item-type="workItemType" :is-modal="isModal" @@ -180,6 +184,7 @@ export default { :is-first-note="true" :note="note" :discussion-id="discussionId" + :full-path="fullPath" :has-replies="hasReplies" :work-item-type="workItemType" :is-modal="isModal" @@ -207,6 +212,7 @@ export default { <work-item-note :key="threadKey(reply)" :discussion-id="discussionId" + :full-path="fullPath" :note="reply" :work-item-type="workItemType" :is-modal="isModal" @@ -231,6 +237,7 @@ export default { v-if="shouldShowReplyForm" :notes-form="false" :autofocus="autofocus" + :full-path="fullPath" :work-item-id="workItemId" :work-item-iid="workItemIid" :discussion-id="discussionId" diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_note.vue index b5e3ea68725..f4c654f054c 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note.vue @@ -3,7 +3,6 @@ import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import toast from '~/vue_shared/plugins/global_toast'; import { __ } from '~/locale'; -import { i18n, TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; import Tracking from '~/tracking'; import { updateDraft, clearDraft } from '~/lib/utils/autosave'; import { renderMarkdown } from '~/notes/utils'; @@ -11,15 +10,17 @@ import { getLocationHash } from '~/lib/utils/url_utility'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import EditedAt from '~/issues/show/components/edited.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; -import NoteBody from '~/work_items/components/notes/work_item_note_body.vue'; import NoteHeader from '~/notes/components/note_header.vue'; -import NoteActions from '~/work_items/components/notes/work_item_note_actions.vue'; -import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import { i18n, TRACKING_CATEGORY_SHOW } from '../../constants'; +import groupWorkItemByIidQuery from '../../graphql/group_work_item_by_iid.query.graphql'; +import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; import updateWorkItemNoteMutation from '../../graphql/notes/update_work_item_note.mutation.graphql'; import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; import { isAssigneesWidget } from '../../utils'; import WorkItemCommentForm from './work_item_comment_form.vue'; +import NoteActions from './work_item_note_actions.vue'; import WorkItemNoteAwardsList from './work_item_note_awards_list.vue'; +import NoteBody from './work_item_note_body.vue'; export default { name: 'WorkItemNoteThread', @@ -35,8 +36,12 @@ export default { EditedAt, }, mixins: [Tracking.mixin()], - inject: ['fullPath'], + inject: ['isGroup'], props: { + fullPath: { + type: String, + required: true, + }, workItemId: { type: String, required: true, @@ -169,7 +174,9 @@ export default { }, apollo: { workItem: { - query: workItemByIidQuery, + query() { + return this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery; + }, variables() { return { fullPath: this.fullPath, @@ -335,6 +342,7 @@ export default { </note-header> <div class="gl-display-inline-flex"> <note-actions + :full-path="fullPath" :show-award-emoji="hasAwardEmojiPermission" :work-item-iid="workItemIid" :note="note" @@ -372,7 +380,12 @@ export default { /> </div> <div class="note-awards" :class="isFirstNote ? '' : 'gl-pl-7'"> - <work-item-note-awards-list :note="note" :work-item-iid="workItemIid" :is-modal="isModal" /> + <work-item-note-awards-list + :full-path="fullPath" + :note="note" + :work-item-iid="workItemIid" + :is-modal="isModal" + /> </div> </div> </timeline-entry-item> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue index e5da3d346ae..2cdf8b5ea9d 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue @@ -33,8 +33,11 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - inject: ['fullPath'], props: { + fullPath: { + type: String, + required: true, + }, workItemIid: { type: String, required: true, diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue index 3c30c204ab6..17d22e66530 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue @@ -8,8 +8,11 @@ export default { components: { AwardsList, }, - inject: ['fullPath'], props: { + fullPath: { + type: String, + required: true, + }, workItemIid: { type: String, required: true, diff --git a/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue index f50cfac90f7..49813edf6fc 100644 --- a/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue @@ -43,9 +43,14 @@ export default { type: Boolean, required: true, }, - childPath: { - type: String, - required: true, + /* + This flag is added to manage between two different work items; Task and Objective/Key result. + Status icon is shown on the task while the actual task icon is shown on any Objective/Key result. + */ + showTaskIcon: { + type: Boolean, + required: false, + default: false, }, }, computed: { @@ -69,7 +74,7 @@ export default { return this.childItem.state === STATE_OPEN; }, iconName() { - if (this.childItemType === TASK_TYPE_NAME) { + if (this.childItemType === TASK_TYPE_NAME && !this.showTaskIcon) { return this.isChildItemOpen ? 'issue-open-m' : 'issue-close'; } return WORK_ITEM_NAME_TO_ICON_MAP[this.childItemType]; @@ -78,7 +83,7 @@ export default { return this.childItem.workItemType.name; }, iconClass() { - if (this.childItemType === TASK_TYPE_NAME) { + if (this.childItemType === TASK_TYPE_NAME && !this.showTaskIcon) { return this.isChildItemOpen ? 'gl-text-green-500' : 'gl-text-blue-500'; } return ''; @@ -148,9 +153,8 @@ export default { /> </span> <gl-link - :href="childPath" - class="gl-text-truncate gl-font-weight-semibold" - data-testid="item-title" + :href="childItem.webUrl" + class="gl-overflow-break-word gl-font-weight-semibold" @click="$emit('click', $event)" @mouseover="$emit('mouseover')" @mouseout="$emit('mouseout')" diff --git a/app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue b/app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue index 38d8d239a7e..c0e87f0bb6e 100644 --- a/app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue @@ -69,6 +69,7 @@ export default { badge-tooltip-prop="name" :badge-sr-only-text="assigneesCollapsedTooltip" :class="assigneesContainerClass" + class="gl-white-space-nowrap" > <template #avatar="{ avatar }"> <gl-avatar-link v-gl-tooltip target="blank" :href="avatar.webUrl" :title="avatar.name"> diff --git a/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue b/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue index 7b38e838033..3595ab631df 100644 --- a/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue @@ -7,7 +7,6 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql'; import { WORK_ITEMS_TYPE_MAP, - WORK_ITEM_TYPE_ENUM_TASK, I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, sprintfWorkItem, } from '../../constants'; @@ -29,7 +28,7 @@ export default { childrenType: { type: String, required: false, - default: WORK_ITEM_TYPE_ENUM_TASK, + default: '', }, childrenIds: { type: Array, @@ -53,7 +52,7 @@ export default { return { fullPath: this.fullPath, searchTerm: this.search?.title || this.search, - types: [this.childrenType], + types: this.childrenType ? [this.childrenType] : [], in: this.search ? 'TITLE' : undefined, }; }, @@ -106,6 +105,7 @@ export default { }, handleFocus() { this.searchStarted = true; + this.$emit('searching', true); }, handleMouseOver() { this.timeout = setTimeout(() => { @@ -115,11 +115,22 @@ export default { handleMouseOut() { clearTimeout(this.timeout); }, + handleBlur() { + this.$emit('searching', false); + }, + focusInputText() { + this.$nextTick(() => { + if (this.areWorkItemsToAddValid) { + this.$refs.tokenSelector.$el.querySelector('input[type="text"]').focus(); + } + }); + }, }, }; </script> <template> <gl-token-selector + ref="tokenSelector" v-model="workItemsToAdd" :dropdown-items="availableWorkItems" :loading="isLoading" @@ -131,13 +142,14 @@ export default { @focus="handleFocus" @mouseover.native="handleMouseOver" @mouseout.native="handleMouseOut" + @token-add="focusInputText" + @token-remove="focusInputText" + @blur="handleBlur" > - <template #token-content="{ token }"> - {{ token.title }} - </template> + <template #token-content="{ token }"> {{ token.iid }} {{ token.title }} </template> <template #dropdown-item-content="{ dropdownItem }"> <div class="gl-display-flex"> - <div class="gl-text-secondary gl-mr-4">{{ getIdFromGraphQLId(dropdownItem.id) }}</div> + <div class="gl-text-secondary gl-font-sm gl-mr-4">{{ dropdownItem.iid }}</div> <div class="gl-text-truncate">{{ dropdownItem.title }}</div> </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue index 18aa4d55086..02d2ea24ca0 100644 --- a/app/assets/javascripts/work_items/components/work_item_actions.vue +++ b/app/assets/javascripts/work_items/components/work_item_actions.vue @@ -7,7 +7,6 @@ import { GlModalDirective, GlToggle, } from '@gitlab/ui'; -import { produce } from 'immer'; import * as Sentry from '@sentry/browser'; @@ -15,7 +14,6 @@ import { __, s__ } from '~/locale'; import Tracking from '~/tracking'; import toast from '~/vue_shared/plugins/global_toast'; import { isLoggedIn } from '~/lib/utils/common_utils'; -import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import { sprintfWorkItem, @@ -28,7 +26,6 @@ import { TEST_ID_PROMOTE_ACTION, TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION, TEST_ID_COPY_REFERENCE_ACTION, - WIDGET_TYPE_NOTIFICATIONS, I18N_WORK_ITEM_ERROR_CONVERTING, WORK_ITEM_TYPE_VALUE_KEY_RESULT, WORK_ITEM_TYPE_VALUE_OBJECTIVE, @@ -70,8 +67,12 @@ export default { copyCreateNoteEmailTestId: TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION, deleteActionTestId: TEST_ID_DELETE_ACTION, promoteActionTestId: TEST_ID_PROMOTE_ACTION, - inject: ['fullPath'], + inject: ['isGroup'], props: { + fullPath: { + type: String, + required: true, + }, workItemId: { type: String, required: false, @@ -127,10 +128,6 @@ export default { required: false, default: false, }, - workItemIid: { - type: String, - required: true, - }, }, apollo: { workItemTypes: { @@ -199,80 +196,31 @@ export default { } }, toggleNotifications(subscribed) { - const inputVariables = { - projectPath: this.fullPath, - iid: this.workItemIid, - subscribedState: subscribed, - }; this.$apollo .mutate({ mutation: updateWorkItemNotificationsMutation, variables: { - input: inputVariables, - }, - optimisticResponse: { - updateWorkItemNotificationsSubscription: { - issue: { - id: this.workItemId, - subscribed, - }, - errors: [], - }, - }, - update: ( - cache, - { - data: { - updateWorkItemNotificationsSubscription: { issue = {} }, - }, + input: { + id: this.workItemId, + subscribed, }, - ) => { - // As the mutation and the query both are different, - // overwrite the subscribed value in the cache - this.updateWorkItemNotificationsWidgetCache({ - cache, - issue, - }); }, }) - .then( - ({ - data: { - updateWorkItemNotificationsSubscription: { errors }, - }, - }) => { - if (errors?.length) { - throw new Error(errors[0]); - } - toast( - subscribed ? this.$options.i18n.notificationOn : this.$options.i18n.notificationOff, - ); - }, - ) + .then(({ data }) => { + const { errors } = data.workItemSubscribe; + if (errors?.length) { + throw new Error(errors[0]); + } + + toast( + subscribed ? this.$options.i18n.notificationOn : this.$options.i18n.notificationOff, + ); + }) .catch((error) => { this.$emit('error', error.message); Sentry.captureException(error); }); }, - updateWorkItemNotificationsWidgetCache({ cache, issue }) { - const query = { - query: workItemByIidQuery, - variables: { fullPath: this.fullPath, iid: this.workItemIid }, - }; - // Read the work item object - const sourceData = cache.readQuery(query); - - const newData = produce(sourceData, (draftState) => { - const { widgets } = draftState.workspace.workItems.nodes[0]; - - const widgetNotifications = widgets.find(({ type }) => type === WIDGET_TYPE_NOTIFICATIONS); - // overwrite the subscribed value - widgetNotifications.subscribed = issue.subscribed; - }); - - // write to the cache - cache.writeQuery({ ...query, data: newData }); - }, throwConvertError() { this.$emit('error', this.i18n.convertError); }, @@ -337,7 +285,6 @@ export default { :data-testid="$options.notificationsToggleTestId" class="work-item-notification-toggle" label-position="left" - label-id="notifications-toggle" @change="toggleNotifications($event)" /> </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 f9527884adc..a9aafbb3d84 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -13,7 +13,8 @@ import { import { debounce, uniqueId } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql'; -import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; +import groupUsersSearchQuery from '~/graphql_shared/queries/group_users_search.query.graphql'; +import usersSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; import { n__, s__ } from '~/locale'; import Tracking from '~/tracking'; @@ -54,8 +55,12 @@ export default { GlIntersectionObserver, }, mixins: [Tracking.mixin()], - inject: ['fullPath'], + inject: ['isGroup'], props: { + fullPath: { + type: String, + required: true, + }, workItemId: { type: String, required: true, @@ -99,7 +104,7 @@ export default { apollo: { users: { query() { - return userSearchQuery; + return this.isGroup ? groupUsersSearchQuery : usersSearchQuery; }, variables() { return { diff --git a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue index 139f0f7919c..fd01d855782 100644 --- a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue +++ b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue @@ -4,17 +4,21 @@ import { sprintfWorkItem, WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_HEALTH_STATUS, + WIDGET_TYPE_HIERARCHY, WIDGET_TYPE_ITERATION, WIDGET_TYPE_LABELS, WIDGET_TYPE_MILESTONE, WIDGET_TYPE_PROGRESS, WIDGET_TYPE_START_AND_DUE_DATE, WIDGET_TYPE_WEIGHT, + WORK_ITEM_TYPE_VALUE_KEY_RESULT, + WORK_ITEM_TYPE_VALUE_OBJECTIVE, } from '../constants'; 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 WorkItemParent from './work_item_parent.vue'; export default { components: { @@ -22,6 +26,7 @@ export default { WorkItemMilestone, WorkItemAssignees, WorkItemDueDate, + WorkItemParent, WorkItemWeight: () => import('ee_component/work_items/components/work_item_weight.vue'), WorkItemProgress: () => import('ee_component/work_items/components/work_item_progress.vue'), WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'), @@ -29,8 +34,11 @@ export default { import('ee_component/work_items/components/work_item_health_status.vue'), }, mixins: [glFeatureFlagMixin()], - inject: ['fullPath'], props: { + fullPath: { + type: String, + required: true, + }, workItem: { type: Object, required: true, @@ -81,9 +89,21 @@ export default { workItemHealthStatus() { return this.isWidgetPresent(WIDGET_TYPE_HEALTH_STATUS); }, + workItemHierarchy() { + return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY); + }, workItemMilestone() { return this.isWidgetPresent(WIDGET_TYPE_MILESTONE); }, + showWorkItemParent() { + return ( + this.workItemType === WORK_ITEM_TYPE_VALUE_OBJECTIVE || + this.workItemType === WORK_ITEM_TYPE_VALUE_KEY_RESULT + ); + }, + workItemParent() { + return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY)?.parent; + }, }, methods: { isWidgetPresent(type) { @@ -98,6 +118,7 @@ export default { <work-item-assignees v-if="workItemAssignees" :can-update="canUpdate" + :full-path="fullPath" :work-item-id="workItem.id" :assignees="workItemAssignees.assignees.nodes" :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees" @@ -108,6 +129,7 @@ export default { <work-item-labels v-if="workItemLabels" :can-update="canUpdate" + :full-path="fullPath" :work-item-id="workItem.id" :work-item-iid="workItem.iid" @error="$emit('error', $event)" @@ -123,6 +145,7 @@ export default { /> <work-item-milestone v-if="workItemMilestone" + :full-path="fullPath" :work-item-id="workItem.id" :work-item-milestone="workItemMilestone.milestone" :work-item-type="workItemType" @@ -151,6 +174,7 @@ export default { <work-item-iteration v-if="workItemIteration" class="gl-mb-5" + :full-path="fullPath" :iteration="workItemIteration.iteration" :can-update="canUpdate" :work-item-id="workItem.id" @@ -168,5 +192,14 @@ export default { :work-item-type="workItemType" @error="$emit('error', $event)" /> + <work-item-parent + v-if="showWorkItemParent" + class="gl-mb-5" + :can-update="canUpdate" + :work-item-id="workItem.id" + :work-item-type="workItemType" + :parent="workItemParent" + @error="$emit('error', $event)" + /> </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_created_updated.vue b/app/assets/javascripts/work_items/components/work_item_created_updated.vue index 14e55134048..460b5d35187 100644 --- a/app/assets/javascripts/work_items/components/work_item_created_updated.vue +++ b/app/assets/javascripts/work_items/components/work_item_created_updated.vue @@ -3,10 +3,11 @@ import { GlAvatarLink, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { WORKSPACE_PROJECT } from '~/issues/constants'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import WorkItemStateBadge from '~/work_items/components/work_item_state_badge.vue'; import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; -import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; +import groupWorkItemByIidQuery from '../graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; +import WorkItemStateBadge from './work_item_state_badge.vue'; +import WorkItemTypeIcon from './work_item_type_icon.vue'; export default { components: { @@ -18,8 +19,12 @@ export default { ConfidentialityBadge, GlLoadingIcon, }, - inject: ['fullPath'], + inject: ['isGroup'], props: { + fullPath: { + type: String, + required: true, + }, workItemIid: { type: String, required: false, @@ -59,7 +64,9 @@ export default { }, apollo: { workItem: { - query: workItemByIidQuery, + query() { + return this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery; + }, variables() { return { fullPath: this.fullPath, 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 58bf524f450..b7f3ac93cdb 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -10,6 +10,7 @@ import Tracking from '~/tracking'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import { autocompleteDataSources, markdownPreviewPath } from '../utils'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; +import groupWorkItemByIidQuery from '../graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_DESCRIPTION } from '../constants'; import WorkItemDescriptionRendered from './work_item_description_rendered.vue'; @@ -25,8 +26,12 @@ export default { WorkItemDescriptionRendered, }, mixins: [Tracking.mixin()], - inject: ['fullPath'], + inject: ['isGroup'], props: { + fullPath: { + type: String, + required: true, + }, workItemId: { type: String, required: true, @@ -55,7 +60,9 @@ export default { }, apollo: { workItem: { - query: workItemByIidQuery, + query() { + return this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery; + }, variables() { return { fullPath: this.fullPath, 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 edecd7addcc..53929775684 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -16,7 +16,6 @@ import { getParameterByName, updateHistory, setUrlParams } from '~/lib/utils/url import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { isLoggedIn } from '~/lib/utils/common_utils'; -import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; import { WORKSPACE_PROJECT } from '~/issues/constants'; @@ -37,6 +36,7 @@ import { import workItemUpdatedSubscription from '../graphql/work_item_updated.subscription.graphql'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql'; +import groupWorkItemByIidQuery from '../graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; import { findHierarchyWidgetChildren } from '../utils'; @@ -52,6 +52,7 @@ import WorkItemDetailModal from './work_item_detail_modal.vue'; import WorkItemAwardEmoji from './work_item_award_emoji.vue'; import WorkItemStateToggleButton from './work_item_state_toggle_button.vue'; import WorkItemRelationships from './work_item_relationships/work_item_relationships.vue'; +import WorkItemTypeIcon from './work_item_type_icon.vue'; export default { i18n, @@ -84,7 +85,7 @@ export default { WorkItemRelationships, }, mixins: [glFeatureFlagMixin()], - inject: ['fullPath', 'reportAbusePath'], + inject: ['fullPath', 'isGroup', 'reportAbusePath'], props: { isModal: { type: Boolean, @@ -118,7 +119,9 @@ export default { }, apollo: { workItem: { - query: workItemByIidQuery, + query() { + return this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery; + }, variables() { return { fullPath: this.fullPath, @@ -189,8 +192,8 @@ export default { canAssignUnassignUser() { return this.workItemAssignees && this.canSetWorkItemMetadata; }, - fullPath() { - return this.workItem?.project.fullPath; + projectFullPath() { + return this.workItem?.project?.fullPath; }, workItemsMvc2Enabled() { return this.glFeatures.workItemsMvc2; @@ -460,11 +463,12 @@ export default { v-if="showWorkItemCurrentUserTodos" :work-item-id="workItem.id" :work-item-iid="workItemIid" - :work-item-fullpath="workItem.project.fullPath" + :work-item-fullpath="projectFullPath" :current-user-todos="currentUserTodos" @error="updateError = $event" /> <work-item-actions + :full-path="fullPath" :work-item-id="workItem.id" :subscribed-to-notifications="workItemNotificationsSubscribed" :work-item-type="workItemType" @@ -476,7 +480,6 @@ export default { :work-item-reference="workItem.reference" :work-item-create-note-email="workItem.createNoteEmail" :is-modal="isModal" - :work-item-iid="workItemIid" @deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })" @toggleWorkItemConfidentiality="toggleConfidentiality" @error="updateError = $event" @@ -503,6 +506,7 @@ export default { @error="updateError = $event" /> <work-item-created-updated + :full-path="fullPath" :work-item-iid="workItemIid" :update-in-progress="updateInProgress" /> @@ -535,11 +539,12 @@ export default { v-if="showWorkItemCurrentUserTodos" :work-item-id="workItem.id" :work-item-iid="workItemIid" - :work-item-fullpath="workItem.project.fullPath" + :work-item-fullpath="projectFullPath" :current-user-todos="currentUserTodos" @error="updateError = $event" /> <work-item-actions + :full-path="fullPath" :work-item-id="workItem.id" :subscribed-to-notifications="workItemNotificationsSubscribed" :work-item-type="workItemType" @@ -551,7 +556,6 @@ export default { :work-item-reference="workItem.reference" :work-item-create-note-email="workItem.createNoteEmail" :is-modal="isModal" - :work-item-iid="workItemIid" @deleteWorkItem=" $emit('deleteWorkItem', { workItemType, workItemId: workItem.id }) " @@ -571,12 +575,14 @@ export default { <work-item-attributes-wrapper :class="{ 'gl-md-display-none!': workItemsMvc2Enabled }" class="gl-border-b" + :full-path="fullPath" :work-item="workItem" :work-item-parent-id="workItemParentId" @error="updateError = $event" /> <work-item-description v-if="hasDescriptionWidget" + :full-path="fullPath" :work-item-id="workItem.id" :work-item-iid="workItem.iid" class="gl-pt-5" @@ -585,7 +591,7 @@ export default { <work-item-award-emoji v-if="workItemAwardEmoji" :work-item-id="workItem.id" - :work-item-fullpath="workItem.project.fullPath" + :work-item-fullpath="projectFullPath" :award-emoji="workItemAwardEmoji.awardEmoji" :work-item-iid="workItemIid" @error="updateError = $event" @@ -593,6 +599,7 @@ export default { /> <work-item-tree v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE" + :full-path="fullPath" :work-item-type="workItemType" :parent-work-item-type="workItem.workItemType.name" :work-item-id="workItem.id" @@ -605,12 +612,15 @@ export default { /> <work-item-relationships v-if="showWorkItemLinkedItems" + :work-item-id="workItem.id" :work-item-iid="workItemIid" - :work-item-full-path="workItem.project.fullPath" + :work-item-full-path="projectFullPath" + :work-item-type="workItem.workItemType.name" @showModal="openInModal" /> <work-item-notes v-if="workItemNotes" + :full-path="fullPath" :work-item-id="workItem.id" :work-item-iid="workItem.iid" :work-item-type="workItemType" @@ -629,6 +639,7 @@ export default { :title="$options.i18n.fetchErrorTitle" :description="error" :svg-path="noAccessSvgPath" + :svg-height="null" /> </section> <aside @@ -638,6 +649,7 @@ export default { :class="{ 'is-modal': isModal }" > <work-item-attributes-wrapper + :full-path="fullPath" :work-item="workItem" :work-item-parent-id="workItemParentId" @error="updateError = $event" 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 1405a12a101..3cdbf816421 100644 --- a/app/assets/javascripts/work_items/components/work_item_labels.vue +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -8,6 +8,7 @@ import LabelItem from '~/sidebar/components/labels/labels_select_widget/label_it import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; +import groupWorkItemByIidQuery from '../graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; import { i18n, I18N_WORK_ITEM_ERROR_FETCHING_LABELS, TRACKING_CATEGORY_SHOW } from '../constants'; import { isLabelsWidget } from '../utils'; @@ -37,8 +38,12 @@ export default { LabelItem, }, mixins: [Tracking.mixin()], - inject: ['fullPath'], + inject: ['isGroup'], props: { + fullPath: { + type: String, + required: true, + }, workItemId: { type: String, required: true, @@ -65,7 +70,9 @@ export default { }, apollo: { workItem: { - query: workItemByIidQuery, + query() { + return this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery; + }, variables() { return { fullPath: this.fullPath, diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue index 9d9414b5399..f4de7c1dddc 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue @@ -13,6 +13,7 @@ import { findHierarchyWidgets } from '../../utils'; import { addHierarchyChild, removeHierarchyChild } from '../../graphql/cache_utils'; import reorderWorkItem from '../../graphql/reorder_work_item.mutation.graphql'; import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; +import groupWorkItemByIidQuery from '../../graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; import WorkItemLinkChild from './work_item_link_child.vue'; @@ -20,8 +21,12 @@ export default { components: { WorkItemLinkChild, }, - inject: ['fullPath'], + inject: ['isGroup'], props: { + fullPath: { + type: String, + required: true, + }, workItemType: { type: String, required: false, @@ -83,7 +88,14 @@ export default { const { data } = await this.$apollo.mutate({ mutation: updateWorkItemMutation, variables: { input: { id: child.id, hierarchyWidget: { parentId: null } } }, - update: (cache) => removeHierarchyChild(cache, this.fullPath, this.workItemIid, child), + update: (cache) => + removeHierarchyChild({ + cache, + fullPath: this.fullPath, + iid: this.workItemIid, + isGroup: this.isGroup, + workItem: child, + }), }); if (data.workItemUpdate.errors.length) { @@ -109,7 +121,14 @@ export default { const { data } = await this.$apollo.mutate({ mutation: updateWorkItemMutation, variables: { input: { id: child.id, hierarchyWidget: { parentId: this.workItemId } } }, - update: (cache) => addHierarchyChild(cache, this.fullPath, this.workItemIid, child), + update: (cache) => + addHierarchyChild({ + cache, + fullPath: this.fullPath, + iid: this.workItemIid, + isGroup: this.isGroup, + workItem: child, + }), }); if (data.workItemUpdate.errors.length) { @@ -124,7 +143,7 @@ export default { }, addWorkItemQuery({ iid }) { this.$apollo.addSmartQuery('prefetchedWorkItem', { - query: workItemByIidQuery, + query: this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery, variables: { fullPath: this.fullPath, iid, @@ -206,7 +225,7 @@ export default { update: (store) => { store.updateQuery( { - query: workItemByIidQuery, + query: this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery, variables: { fullPath: this.fullPath, iid: this.workItemIid }, }, (sourceData) => 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 679287338c8..847a3585ac4 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 @@ -13,7 +13,6 @@ import { WIDGET_TYPE_HIERARCHY, WORK_ITEM_NAME_TO_ICON_MAP, } from '../../constants'; -import { workItemPath } from '../../utils'; import getWorkItemTreeQuery from '../../graphql/work_item_tree.query.graphql'; import WorkItemLinkChildContents from '../shared/work_item_link_child_contents.vue'; import WorkItemTreeChildren from './work_item_tree_children.vue'; @@ -27,7 +26,6 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - inject: ['fullPath'], props: { canUpdate: { type: Boolean, @@ -90,9 +88,6 @@ export default { stateTimestampTypeText() { return this.isItemOpen ? __('Created') : __('Closed'); }, - childPath() { - return workItemPath(this.fullPath, this.childItem.iid); - }, chevronType() { return this.isExpanded ? 'chevron-down' : 'chevron-right'; }, @@ -236,7 +231,6 @@ export default { :can-update="canUpdate" :parent-work-item-id="issuableGid" :work-item-type="workItemType" - :child-path="childPath" @click="$emit('click', $event)" @removeChild="$emit('removeChild', childItem)" /> 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 eb836007e75..7fa6ac2c57f 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 @@ -18,6 +18,7 @@ import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_sel import { FORM_TYPES, WIDGET_ICONS, WORK_ITEM_STATUS_TEXT } from '../../constants'; import { findHierarchyWidgetChildren } from '../../utils'; import { removeHierarchyChild } from '../../graphql/cache_utils'; +import groupWorkItemByIidQuery from '../../graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; import WidgetWrapper from '../widget_wrapper.vue'; import WorkItemDetailModal from '../work_item_detail_modal.vue'; @@ -39,7 +40,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - inject: ['fullPath', 'reportAbusePath'], + inject: ['fullPath', 'isGroup', 'reportAbusePath'], props: { issuableId: { type: Number, @@ -52,7 +53,9 @@ export default { }, apollo: { workItem: { - query: workItemByIidQuery, + query() { + return this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery; + }, variables() { return { fullPath: this.fullPath, @@ -171,7 +174,13 @@ export default { }, handleWorkItemDeleted(child) { const { defaultClient: cache } = this.$apollo.provider.clients; - removeHierarchyChild(cache, this.fullPath, this.iid, child); + removeHierarchyChild({ + cache, + fullPath: this.fullPath, + iid: this.iid, + isGroup: this.isGroup, + workItem: child, + }); this.$toast.show(s__('WorkItem|Task deleted')); }, updateWorkItemIdUrlQuery({ iid } = {}) { @@ -256,6 +265,7 @@ export default { v-if="isShownAddForm" ref="wiLinksForm" data-testid="add-links-form" + :full-path="fullPath" :issuable-gid="issuableGid" :work-item-iid="iid" :children-ids="childrenIds" @@ -269,6 +279,7 @@ export default { <work-item-children-wrapper :children="children" :can-update="canUpdate" + :full-path="fullPath" :work-item-id="issuableGid" :work-item-iid="iid" @error="error = $event" 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 55440e1603c..f24b56cac36 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 @@ -37,8 +37,12 @@ export default { GlTooltip, WorkItemTokenInput, }, - inject: ['fullPath', 'hasIterationsFeature'], + inject: ['hasIterationsFeature', 'isGroup'], props: { + fullPath: { + type: String, + required: true, + }, issuableGid: { type: String, required: false, @@ -225,7 +229,6 @@ export default { this.error = null; }, addChild() { - this.searchStarted = false; this.$apollo .mutate({ mutation: updateWorkItemMutation, @@ -261,7 +264,13 @@ export default { input: this.workItemInput, }, update: (cache, { data }) => - addHierarchyChild(cache, this.fullPath, this.workItemIid, data.workItemCreate.workItem), + addHierarchyChild({ + cache, + fullPath: this.fullPath, + iid: this.workItemIid, + isGroup: this.isGroup, + workItem: data.workItemCreate.workItem, + }), }) .then(({ data }) => { if (data.workItemCreate?.errors?.length) { 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 index bc3f5201fb8..b61b3b2e0d3 100644 --- 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 @@ -22,8 +22,11 @@ export default { WorkItemLinksForm, WorkItemChildrenWrapper, }, - inject: ['fullPath'], props: { + fullPath: { + type: String, + required: true, + }, workItemType: { type: String, required: true, @@ -139,6 +142,7 @@ export default { v-if="isShownAddForm" ref="wiLinksForm" data-testid="add-tree-form" + :full-path="fullPath" :issuable-gid="workItemId" :work-item-iid="workItemIid" :form-type="formType" @@ -152,6 +156,7 @@ export default { <work-item-children-wrapper :children="children" :can-update="canUpdate" + :full-path="fullPath" :work-item-id="workItemId" :work-item-iid="workItemIid" :work-item-type="workItemType" 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 index 2cabf489bc6..401223c3593 100644 --- 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 @@ -3,7 +3,6 @@ export default { components: { WorkItemLinkChild: () => import('./work_item_link_child.vue'), }, - inject: ['fullPath'], props: { workItemType: { type: String, 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 6cc61ed4756..a2cbb7f7598 100644 --- a/app/assets/javascripts/work_items/components/work_item_milestone.vue +++ b/app/assets/javascripts/work_items/components/work_item_milestone.vue @@ -46,8 +46,11 @@ export default { GlDropdownText, }, mixins: [Tracking.mixin()], - inject: ['fullPath'], props: { + fullPath: { + type: String, + required: true, + }, workItemId: { type: String, required: true, diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue index 256f8ed53d1..fe8aea99f53 100644 --- a/app/assets/javascripts/work_items/components/work_item_notes.vue +++ b/app/assets/javascripts/work_items/components/work_item_notes.vue @@ -46,8 +46,11 @@ export default { WorkItemNotesActivityHeader, WorkItemHistoryOnlyFilterNote, }, - inject: ['fullPath'], props: { + fullPath: { + type: String, + required: true, + }, workItemId: { type: String, required: true, @@ -364,6 +367,7 @@ export default { <work-item-discussion :key="getDiscussionKey(discussion)" :discussion="discussion.notes.nodes" + :full-path="fullPath" :work-item-id="workItemId" :work-item-iid="workItemIid" :work-item-type="workItemType" diff --git a/app/assets/javascripts/work_items/components/work_item_parent.vue b/app/assets/javascripts/work_items/components/work_item_parent.vue new file mode 100644 index 00000000000..e16299f482f --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_parent.vue @@ -0,0 +1,249 @@ +<script> +import { GlFormGroup, GlCollapsibleListbox } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { debounce } from 'lodash'; + +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { s__ } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; + +import projectWorkItemsQuery from '../graphql/project_work_items.query.graphql'; +import { + I18N_WORK_ITEM_ERROR_UPDATING, + sprintfWorkItem, + WORK_ITEM_TYPE_ENUM_OBJECTIVE, +} from '../constants'; + +export default { + i18n: { + assignParentLabel: s__('WorkItem|Assign parent'), + parentLabel: s__('WorkItem|Parent'), + none: s__('WorkItem|None'), + noMatchingResults: s__('WorkItem|No matching results'), + unAssign: s__('WorkItem|Unassign'), + workItemsFetchError: s__( + 'WorkItem|Something went wrong while fetching items. Please try again.', + ), + }, + components: { + GlFormGroup, + GlCollapsibleListbox, + }, + mixins: [glFeatureFlagMixin()], + inject: ['fullPath'], + props: { + workItemId: { + type: String, + required: true, + }, + parent: { + type: Object, + required: false, + default: () => {}, + }, + workItemType: { + type: String, + required: false, + default: '', + }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + search: '', + updateInProgress: false, + searchStarted: false, + availableWorkItems: [], + localSelectedItem: this.parent?.id, + isNotFocused: true, + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.availableWorkItems.loading; + }, + listboxText() { + return ( + this.workItems.filter((item) => this.localSelectedItem === item.value)?.[0]?.text || + this.parent?.title || + this.$options.i18n.none + ); + }, + workItemsMvc2Enabled() { + return this.glFeatures.workItemsMvc2; + }, + workItems() { + return this.availableWorkItems.map(({ id, title }) => ({ text: title, value: id })); + }, + listboxCategory() { + return this.searchStarted ? 'secondary' : 'tertiary'; + }, + listboxClasses() { + return { + 'is-not-focused': this.isNotFocused && !this.searchStarted, + }; + }, + }, + watch: { + parent: { + handler(newVal) { + this.localSelectedItem = newVal?.id; + }, + }, + }, + created() { + this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + apollo: { + availableWorkItems: { + query: projectWorkItemsQuery, + variables() { + return { + fullPath: this.fullPath, + searchTerm: this.search, + types: [WORK_ITEM_TYPE_ENUM_OBJECTIVE], + in: this.search ? 'TITLE' : undefined, + }; + }, + skip() { + return !this.searchStarted; + }, + update(data) { + return data.workspace.workItems.nodes.filter((wi) => this.workItemId !== wi.id) || []; + }, + error() { + this.$emit('error', this.$options.i18n.workItemsFetchError); + }, + }, + }, + methods: { + setSearchKey(value) { + this.search = value; + }, + async updateParent() { + if (this.parent?.id === this.localSelectedItem) { + return; + } + this.updateInProgress = true; + try { + const { + data: { + workItemUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + hierarchyWidget: { + parentId: + this.localSelectedItem === 'no-work-item-id' ? null : this.localSelectedItem, + }, + }, + }, + }); + + if (errors.length) { + this.$emit('error', errors.join('\n')); + this.localSelectedItem = this.parent?.id || 'no-work-item-id'; + } + } catch (error) { + this.$emit('error', sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType)); + Sentry.captureException(error); + } finally { + this.updateInProgress = false; + } + }, + handleItemClick(item) { + this.localSelectedItem = item; + this.searchStarted = false; + this.search = ''; + this.updateParent(); + }, + unAssignParent() { + this.localSelectedItem = 'no-work-item-id'; + this.updateParent(); + }, + onListboxShown() { + this.searchStarted = true; + this.isNotFocused = false; + }, + onListboxHide() { + this.searchStarted = false; + this.search = ''; + this.isNotFocused = true; + }, + setListboxFocused() { + // This is to match the caret behaviour of parent listbox + // to the other dropdown fields of work items + if (document.activeElement.parentElement.id !== 'work-item-parent-listbox-value') { + this.isNotFocused = true; + } + }, + }, +}; +</script> + +<template> + <gl-form-group + class="work-item-dropdown gl-flex-nowrap" + data-testid="work-item-parent-form" + :label="$options.i18n.parentLabel" + label-for="work-item-parent-listbox-value" + label-class="gl-pb-0! gl-mt-3 gl-overflow-wrap-break work-item-field-label" + label-cols="3" + label-cols-lg="2" + > + <span + v-if="!canUpdate" + class="gl-text-secondary gl-ml-4 gl-mt-3 gl-display-inline-block gl-line-height-normal work-item-field-value" + data-testid="disabled-text" + > + {{ listboxText }} + </span> + <div + v-else + :class="{ 'gl-max-w-max-content': !workItemsMvc2Enabled }" + @mouseover="isNotFocused = false" + @mouseleave="setListboxFocused" + @focusout="isNotFocused = true" + @focusin="isNotFocused = false" + > + <gl-collapsible-listbox + id="work-item-parent-listbox-value" + class="gl-max-w-max-content" + data-testid="work-item-parent-listbox" + block + searchable + :no-caret="isNotFocused && !searchStarted" + is-check-centered + :category="listboxCategory" + :searching="isLoading" + :header-text="$options.i18n.assignParentLabel" + :no-results-text="$options.i18n.noMatchingResults" + :loading="updateInProgress" + :items="workItems" + :toggle-text="listboxText" + :toggle-class="listboxClasses" + :selected="localSelectedItem" + :reset-button-label="$options.i18n.unAssign" + @reset="unAssignParent" + @search="debouncedSearchKeyUpdate" + @select="handleItemClick" + @shown="onListboxShown" + @hidden="onListboxHide" + > + <template #list-item="{ item }"> + <div @click="handleItemClick(item.value, $event)"> + {{ item.text }} + </div> + </template> + </gl-collapsible-listbox> + </div> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue new file mode 100644 index 00000000000..d242db95896 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue @@ -0,0 +1,249 @@ +<script> +import { produce } from 'immer'; +import { GlFormGroup, GlForm, GlFormRadioGroup, GlButton, GlAlert } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import WorkItemTokenInput from '../shared/work_item_token_input.vue'; +import addLinkedItemsMutation from '../../graphql/add_linked_items.mutation.graphql'; +import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; +import { + LINK_ITEM_FORM_HEADER_LABEL, + WIDGET_TYPE_LINKED_ITEMS, + LINKED_ITEM_TYPE_VALUE, + MAX_WORK_ITEMS, + I18N_MAX_WORK_ITEMS_ERROR_MESSAGE, + I18N_MAX_WORK_ITEMS_NOTE_LABEL, +} from '../../constants'; + +export default { + components: { + GlForm, + GlButton, + GlFormGroup, + GlFormRadioGroup, + GlAlert, + WorkItemTokenInput, + }, + props: { + workItemId: { + type: String, + required: false, + default: null, + }, + workItemIid: { + type: String, + required: false, + default: null, + }, + workItemFullPath: { + type: String, + required: false, + default: null, + }, + workItemType: { + type: String, + required: false, + default: null, + }, + childrenIds: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + return { + linkedItemType: LINKED_ITEM_TYPE_VALUE.RELATED, + linkedItemTypes: [ + { + text: this.$options.i18n.relatedToLabel, + value: LINKED_ITEM_TYPE_VALUE.RELATED, + }, + { + text: this.$options.i18n.blockingLabel, + value: LINKED_ITEM_TYPE_VALUE.BLOCKS, + }, + { + text: this.$options.i18n.blockedByLabel, + value: LINKED_ITEM_TYPE_VALUE.BLOCKED_BY, + }, + ], + workItemsToAdd: [], + error: null, + showWorkItemsToAddInvalidMessage: false, + isSubmitting: false, + searchInProgress: false, + maxWorkItems: MAX_WORK_ITEMS, + }; + }, + computed: { + linkItemFormHeaderLabel() { + return LINK_ITEM_FORM_HEADER_LABEL[this.workItemType]; + }, + workItemsToAddInvalidMessage() { + return this.$options.i18n.addChildErrorMessage; + }, + isSubmitButtonDisabled() { + return this.workItemsToAdd.length <= 0 || !this.areWorkItemsToAddValid; + }, + areWorkItemsToAddValid() { + return this.workItemsToAdd.length <= this.maxWorkItems; + }, + errorMessage() { + return !this.areWorkItemsToAddValid ? this.$options.i18n.maxItemsErrorMessage : ''; + }, + }, + methods: { + async linkWorkItem() { + try { + if (this.searchInProgress) { + return; + } + this.isSubmitting = true; + const { + data: { + workItemAddLinkedItems: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: addLinkedItemsMutation, + variables: { + input: { + id: this.workItemId, + linkType: this.linkedItemType, + workItemsIds: this.workItemsToAdd.map((wi) => wi.id), + }, + }, + update: ( + cache, + { + data: { + workItemAddLinkedItems: { workItem }, + }, + }, + ) => { + const queryArgs = { + query: workItemByIidQuery, + variables: { fullPath: this.workItemFullPath, iid: this.workItemIid }, + }; + const sourceData = cache.readQuery(queryArgs); + + if (!sourceData) { + return; + } + + cache.writeQuery({ + ...queryArgs, + data: produce(sourceData, (draftState) => { + const linkedItemsWidget = draftState.workspace.workItems.nodes[0].widgets?.find( + (widget) => widget.type === WIDGET_TYPE_LINKED_ITEMS, + ); + + linkedItemsWidget.linkedItems = workItem.widgets?.find( + (widget) => widget.type === WIDGET_TYPE_LINKED_ITEMS, + ).linkedItems; + }), + }); + }, + }); + + if (errors.length > 0) { + [this.error] = errors; + return; + } + + this.workItemsToAdd = []; + this.unsetError(); + this.showWorkItemsToAddInvalidMessage = false; + this.linkedItemType = LINKED_ITEM_TYPE_VALUE.RELATED; + this.$emit('submitted'); + } catch (e) { + this.error = this.$options.i18n.addLinkedItemErrorMessage; + } finally { + this.isSubmitting = false; + } + }, + unsetError() { + this.error = null; + }, + }, + i18n: { + addButtonLabel: __('Add'), + relatedToLabel: s__('WorkItem|relates to'), + blockingLabel: s__('WorkItem|blocks'), + blockedByLabel: s__('WorkItem|is blocked by'), + linkItemInputLabel: s__('WorkItem|the following item(s)'), + addLinkedItemErrorMessage: s__( + 'WorkItem|Something went wrong when trying to link a item. Please try again.', + ), + maxItemsNoteLabel: I18N_MAX_WORK_ITEMS_NOTE_LABEL, + maxItemsErrorMessage: I18N_MAX_WORK_ITEMS_ERROR_MESSAGE, + }, +}; +</script> + +<template> + <gl-form + class="gl-new-card-add-form" + data-testid="link-work-item-form" + @submit.stop.prevent="linkWorkItem" + > + <gl-alert v-if="error" variant="danger" class="gl-mb-3" @dismiss="unsetError"> + {{ error }} + </gl-alert> + <gl-form-group + :label="linkItemFormHeaderLabel" + label-for="linked-item-type-radio" + label-class="label-bold" + class="gl-mb-3" + > + <gl-form-radio-group + id="linked-item-type-radio" + v-model="linkedItemType" + :options="linkedItemTypes" + :checked="linkedItemType" + /> + </gl-form-group> + <p class="gl-font-weight-bold gl-mb-2"> + {{ $options.i18n.linkItemInputLabel }} + </p> + <div class="gl-mb-5"> + <work-item-token-input + v-model="workItemsToAdd" + class="gl-mb-2" + :parent-work-item-id="workItemId" + :children-ids="childrenIds" + :are-work-items-to-add-valid="areWorkItemsToAddValid" + :full-path="workItemFullPath" + :max-selection-limit="maxWorkItems" + @searching="searchInProgress = $event" + /> + <div v-if="errorMessage" class="gl-mb-2 gl-text-red-500"> + {{ $options.i18n.maxItemsErrorMessage }} + </div> + <div v-if="!errorMessage" data-testid="max-work-item-note" class="gl-text-gray-500"> + {{ $options.i18n.maxItemsNoteLabel }} + </div> + <div + v-if="showWorkItemsToAddInvalidMessage" + class="gl-text-red-500" + data-testid="work-items-invalid" + > + {{ workItemsToAddInvalidMessage }} + </div> + </div> + <gl-button + data-testid="link-work-item-button" + category="primary" + variant="confirm" + size="small" + type="submit" + :disabled="isSubmitButtonDisabled" + :loading="isSubmitting" + class="gl-mr-2" + > + {{ $options.i18n.addButtonLabel }} + </gl-button> + <gl-button category="secondary" size="small" @click="$emit('cancel')"> + {{ s__('WorkItem|Cancel') }} + </gl-button> + </gl-form> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue index cbe830f9565..002c1786044 100644 --- a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue +++ b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue @@ -1,6 +1,5 @@ <script> import WorkItemLinkChildContents from '../shared/work_item_link_child_contents.vue'; -import { workItemPath } from '../../utils'; export default { components: { @@ -20,20 +19,11 @@ export default { type: Boolean, required: true, }, - workItemFullPath: { - type: String, - required: true, - }, - }, - methods: { - linkedItemPath(fullPath, id) { - return workItemPath(fullPath, id); - }, }, }; </script> <template> - <div> + <div data-testid="work-item-linked-items-list"> <h4 v-if="heading" data-testid="work-items-list-heading" @@ -51,8 +41,9 @@ export default { <work-item-link-child-contents :child-item="linkedItem.workItem" :can-update="canUpdate" - :child-path="linkedItemPath(workItemFullPath, linkedItem.workItem.iid)" + :show-task-icon="true" @click="$emit('showModal', { event: $event, child: linkedItem.workItem })" + @removeChild="$emit('removeLinkedItem', linkedItem.workItem)" /> </li> </ul> diff --git a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue index 4f6879e9605..20427fe96c4 100644 --- a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue +++ b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue @@ -1,23 +1,37 @@ <script> -import { GlLoadingIcon, GlIcon, GlButton } from '@gitlab/ui'; +import { produce } from 'immer'; +import { GlLoadingIcon, GlIcon, GlButton, GlLink } from '@gitlab/ui'; import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import groupWorkItemByIidQuery from '../../graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; +import removeLinkedItemsMutation from '../../graphql/remove_linked_items.mutation.graphql'; import { WIDGET_TYPE_LINKED_ITEMS, LINKED_CATEGORIES_MAP } from '../../constants'; import WidgetWrapper from '../widget_wrapper.vue'; import WorkItemRelationshipList from './work_item_relationship_list.vue'; +import WorkItemAddRelationshipForm from './work_item_add_relationship_form.vue'; export default { + helpPath: helpPagePath('/user/okrs.md#linked-items-in-okrs'), components: { GlLoadingIcon, GlIcon, GlButton, + GlLink, WidgetWrapper, WorkItemRelationshipList, + WorkItemAddRelationshipForm, }, + inject: ['isGroup'], props: { + workItemId: { + type: String, + required: false, + default: null, + }, workItemIid: { type: String, required: true, @@ -26,10 +40,17 @@ export default { type: String, required: true, }, + workItemType: { + type: String, + required: false, + default: null, + }, }, apollo: { workItem: { - query: workItemByIidQuery, + query() { + return this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery; + }, variables() { return { fullPath: this.workItemFullPath, @@ -74,13 +95,13 @@ export default { linksRelatesTo: [], linksIsBlockedBy: [], linksBlocks: [], + isShownLinkItemForm: false, widgetName: 'linkeditems', }; }, computed: { - canUpdate() { - // This will be false untill we implement remove item mutation - return false; + canAdminWorkItemLink() { + return this.workItem?.userPermissions?.adminWorkItemLink; }, isLoading() { return this.$apollo.queries.workItem.loading; @@ -91,18 +112,88 @@ export default { linkedWorkItems() { return this.linkedWorkItemsWidget?.linkedItems?.nodes || []; }, + childrenIds() { + return this.linkedWorkItems.map((item) => item.workItem.id); + }, linkedWorkItemsCount() { return this.linkedWorkItems.length; }, isEmptyRelatedWorkItems() { - return !this.error && this.linkedWorkItems.length === 0; + return !this.isShownLinkItemForm && !this.error && this.linkedWorkItems.length === 0; + }, + }, + methods: { + showLinkItemForm() { + this.isShownLinkItemForm = true; + }, + hideLinkItemForm() { + this.isShownLinkItemForm = false; + }, + async removeLinkedItem(linkedItem) { + try { + const { + data: { + workItemRemoveLinkedItems: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: removeLinkedItemsMutation, + variables: { + input: { + id: this.workItemId, + workItemsIds: [linkedItem.id], + }, + }, + update: (cache, { data: { workItemRemoveLinkedItems } }) => { + const errorMessages = workItemRemoveLinkedItems?.errors; + if (errorMessages && errorMessages.length > 0) { + [this.error] = errorMessages; + return; + } + const queryArgs = { + query: workItemByIidQuery, + variables: { fullPath: this.workItemFullPath, iid: this.workItemIid }, + }; + const sourceData = cache.readQuery(queryArgs); + + if (!sourceData) { + return; + } + + cache.writeQuery({ + ...queryArgs, + data: produce(sourceData, (draftState) => { + const linkedItems = + draftState.workspace.workItems.nodes[0].widgets?.find( + (widget) => widget.type === WIDGET_TYPE_LINKED_ITEMS, + )?.linkedItems?.nodes || []; + const index = linkedItems.findIndex((item) => { + return item.workItem.id === linkedItem.id; + }); + linkedItems.splice(index, 1); + }), + }); + }, + }); + + if (errors.length > 0) { + [this.error] = errors; + return; + } + + this.$toast.show(s__('WorkItem|Linked item removed')); + } catch { + this.error = this.$options.i18n.removeLinkedItemErrorMessage; + } }, }, i18n: { title: s__('WorkItem|Linked Items'), - fetchError: s__('WorkItem|Something went wrong when fetching tasks. Please refresh this page.'), + fetchError: s__('WorkItem|Something went wrong when fetching items. Please refresh this page.'), emptyStateMessage: s__( - "WorkItem|Link work items together to show that they're related or that one is blocking others.", + "WorkItem|Link items together to show that they're related or that one is blocking others.", + ), + removeLinkedItemErrorMessage: s__( + 'WorkItem|Something went wrong when removing item. Please refresh this page.', ), addChildButtonLabel: s__('WorkItem|Add'), relatedToTitle: s__('WorkItem|Related to'), @@ -131,17 +222,36 @@ export default { </div> </template> <template #header-right> - <gl-button size="small" class="gl-ml-3"> + <gl-button + v-if="canAdminWorkItemLink" + data-testid="link-item-add-button" + size="small" + class="gl-ml-3" + @click="showLinkItemForm" + > <slot name="add-button-text">{{ $options.i18n.addLinkedWorkItemButtonLabel }}</slot> </gl-button> </template> <template #body> <div class="gl-new-card-content"> + <work-item-add-relationship-form + v-if="isShownLinkItemForm" + :work-item-id="workItemId" + :work-item-iid="workItemIid" + :work-item-full-path="workItemFullPath" + :children-ids="childrenIds" + :work-item-type="workItemType" + @submitted="hideLinkItemForm" + @cancel="hideLinkItemForm" + /> <gl-loading-icon v-if="isLoading" color="dark" class="gl-my-2" /> <template v-else> - <div v-if="isEmptyRelatedWorkItems" data-testid="links-empty"> + <div v-if="!isShownLinkItemForm && isEmptyRelatedWorkItems" data-testid="links-empty"> <p class="gl-new-card-empty"> {{ $options.i18n.emptyStateMessage }} + <gl-link :href="$options.helpPath" data-testid="help-link"> + {{ __('Learn more.') }} + </gl-link> </p> </div> <template v-else> @@ -153,9 +263,9 @@ export default { }" :linked-items="linksBlocks" :heading="$options.i18n.blockingTitle" - :work-item-full-path="workItemFullPath" - :can-update="canUpdate" + :can-update="canAdminWorkItemLink" @showModal="$emit('showModal', { event: $event.event, modalWorkItem: $event.child })" + @removeLinkedItem="removeLinkedItem" /> <work-item-relationship-list v-if="linksIsBlockedBy.length" @@ -165,17 +275,17 @@ export default { }" :linked-items="linksIsBlockedBy" :heading="$options.i18n.blockedByTitle" - :work-item-full-path="workItemFullPath" - :can-update="canUpdate" + :can-update="canAdminWorkItemLink" @showModal="$emit('showModal', { event: $event.event, modalWorkItem: $event.child })" + @removeLinkedItem="removeLinkedItem" /> <work-item-relationship-list v-if="linksRelatesTo.length" :linked-items="linksRelatesTo" :heading="$options.i18n.relatedToTitle" - :work-item-full-path="workItemFullPath" - :can-update="canUpdate" + :can-update="canAdminWorkItemLink" @showModal="$emit('showModal', { event: $event.event, modalWorkItem: $event.child })" + @removeLinkedItem="removeLinkedItem" /> </template> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_todos.vue b/app/assets/javascripts/work_items/components/work_item_todos.vue index b21abf21be5..e6d7f2067ba 100644 --- a/app/assets/javascripts/work_items/components/work_item_todos.vue +++ b/app/assets/javascripts/work_items/components/work_item_todos.vue @@ -4,9 +4,10 @@ import { produce } from 'immer'; import { s__ } from '~/locale'; import { updateGlobalTodoCount } from '~/sidebar/utils'; -import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; -import createWorkItemTodosMutation from '~/work_items/graphql/create_work_item_todos.mutation.graphql'; -import markDoneWorkItemTodosMutation from '~/work_items/graphql/mark_done_work_item_todos.mutation.graphql'; +import groupWorkItemByIidQuery from '../graphql/group_work_item_by_iid.query.graphql'; +import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; +import createWorkItemTodosMutation from '../graphql/create_work_item_todos.mutation.graphql'; +import markDoneWorkItemTodosMutation from '../graphql/mark_done_work_item_todos.mutation.graphql'; import { TODO_ADD_ICON, @@ -28,6 +29,7 @@ export default { GlIcon, GlButton, }, + inject: ['isGroup'], props: { workItemId: { type: String, @@ -148,7 +150,7 @@ export default { }, updateWorkItemCurrentTodosWidgetCache({ cache, todos }) { const query = { - query: workItemByIidQuery, + query: this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery, variables: { fullPath: this.workItemFullpath, iid: this.workItemIid }, }; 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 5426f3965b3..76a73093206 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 @@ -36,6 +36,11 @@ export default { return this.workItemType.toUpperCase().split(' ').join('_'); }, iconName() { + // TODO Delete this conditional once we have an `issue-type-epic` icon + if (this.workItemIconName === 'issue-type-epic') { + return 'epic'; + } + return ( this.workItemIconName || WORK_ITEMS_TYPE_MAP[this.workItemTypeUppercase]?.icon || diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 2b118247426..a64172acff4 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -112,8 +112,19 @@ export const I18N_WORK_ITEM_COPY_CREATE_NOTE_EMAIL = s__( 'WorkItem|Copy %{workItemType} email address', ); +export const MAX_WORK_ITEMS = 10; + +export const I18N_MAX_WORK_ITEMS_ERROR_MESSAGE = sprintf( + s__('WorkItem|Only %{MAX_WORK_ITEMS} items can be added at a time.'), + { MAX_WORK_ITEMS }, +); +export const I18N_MAX_WORK_ITEMS_NOTE_LABEL = sprintf( + s__('WorkItem|Add a maximum of %{MAX_WORK_ITEMS} items at a time.'), + { MAX_WORK_ITEMS }, +); + export const sprintfWorkItem = (msg, workItemTypeArg, parentWorkItemType = '') => { - const workItemType = workItemTypeArg || s__('WorkItem|Work item'); + const workItemType = workItemTypeArg || s__('WorkItem|item'); return capitalizeFirstCharacter( sprintf(msg, { workItemType: workItemType.toLocaleLowerCase(), @@ -186,8 +197,11 @@ export const WORK_ITEM_NAME_TO_ICON_MAP = { Issue: 'issue-type-issue', Task: 'issue-type-task', Objective: 'issue-type-objective', + Incident: 'issue-type-incident', // eslint-disable-next-line @gitlab/require-i18n-strings 'Key Result': 'issue-type-keyresult', + // eslint-disable-next-line @gitlab/require-i18n-strings + 'Test Case': 'issue-type-test-case', }; export const FORM_TYPES = { @@ -262,3 +276,15 @@ export const LINKED_CATEGORIES_MAP = { IS_BLOCKED_BY: 'is_blocked_by', BLOCKS: 'blocks', }; + +export const LINKED_ITEM_TYPE_VALUE = { + RELATED: 'RELATED', + BLOCKED_BY: 'BLOCKED_BY', + BLOCKS: 'BLOCKS', +}; + +export const LINK_ITEM_FORM_HEADER_LABEL = { + [WORK_ITEM_TYPE_VALUE_OBJECTIVE]: s__('WorkItem|The current objective'), + [WORK_ITEM_TYPE_VALUE_KEY_RESULT]: s__('WorkItem|The current key result'), + [WORK_ITEM_TYPE_VALUE_TASK]: s__('WorkItem|The current task'), +}; diff --git a/app/assets/javascripts/work_items/graphql/add_linked_items.mutation.graphql b/app/assets/javascripts/work_items/graphql/add_linked_items.mutation.graphql new file mode 100644 index 00000000000..ba12c7f9b51 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/add_linked_items.mutation.graphql @@ -0,0 +1,10 @@ +#import "./work_item.fragment.graphql" + +mutation addLinkedItems($input: WorkItemAddLinkedItemsInput!) { + workItemAddLinkedItems(input: $input) { + workItem { + ...WorkItem + } + errors + } +} diff --git a/app/assets/javascripts/work_items/graphql/cache_utils.js b/app/assets/javascripts/work_items/graphql/cache_utils.js index 14eedf5cdd8..aeeffea24e7 100644 --- a/app/assets/javascripts/work_items/graphql/cache_utils.js +++ b/app/assets/javascripts/work_items/graphql/cache_utils.js @@ -1,5 +1,6 @@ import { produce } from 'immer'; import { WIDGET_TYPE_NOTES } from '~/work_items/constants'; +import groupWorkItemByIidQuery from '~/work_items/graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import { findHierarchyWidgetChildren } from '~/work_items/utils'; @@ -127,8 +128,11 @@ export const updateCacheAfterRemovingAwardEmojiFromNote = (currentNotes, note) = }); }; -export const addHierarchyChild = (cache, fullPath, iid, workItem) => { - const queryArgs = { query: workItemByIidQuery, variables: { fullPath, iid } }; +export const addHierarchyChild = ({ cache, fullPath, iid, isGroup, workItem }) => { + const queryArgs = { + query: isGroup ? groupWorkItemByIidQuery : workItemByIidQuery, + variables: { fullPath, iid }, + }; const sourceData = cache.readQuery(queryArgs); if (!sourceData) { @@ -143,8 +147,11 @@ export const addHierarchyChild = (cache, fullPath, iid, workItem) => { }); }; -export const removeHierarchyChild = (cache, fullPath, iid, workItem) => { - const queryArgs = { query: workItemByIidQuery, variables: { fullPath, iid } }; +export const removeHierarchyChild = ({ cache, fullPath, iid, isGroup, workItem }) => { + const queryArgs = { + query: isGroup ? groupWorkItemByIidQuery : workItemByIidQuery, + variables: { fullPath, iid }, + }; const sourceData = cache.readQuery(queryArgs); if (!sourceData) { diff --git a/app/assets/javascripts/work_items/graphql/group_work_item_by_iid.query.graphql b/app/assets/javascripts/work_items/graphql/group_work_item_by_iid.query.graphql new file mode 100644 index 00000000000..f23bafa20c3 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/group_work_item_by_iid.query.graphql @@ -0,0 +1,12 @@ +#import "./work_item.fragment.graphql" + +query groupWorkItemByIid($fullPath: ID!, $iid: String) { + workspace: group(fullPath: $fullPath) @persist { + id + workItems(iid: $iid) { + nodes { + ...WorkItem + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql index 7d63af448d4..2be436aa8c2 100644 --- a/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql +++ b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql @@ -9,6 +9,7 @@ query projectWorkItems( workItems(search: $searchTerm, types: $types, in: $in) { nodes { id + iid title state confidential diff --git a/app/assets/javascripts/work_items/graphql/remove_linked_items.mutation.graphql b/app/assets/javascripts/work_items/graphql/remove_linked_items.mutation.graphql new file mode 100644 index 00000000000..f83f5474606 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/remove_linked_items.mutation.graphql @@ -0,0 +1,6 @@ +mutation removeLinkedItems($input: WorkItemRemoveLinkedItemsInput!) { + workItemRemoveLinkedItems(input: $input) { + errors + message + } +} diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql index f28317b79b5..9d71d452430 100644 --- a/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql @@ -1,9 +1,14 @@ -mutation updateWorkItemNotificationsWidget($input: IssueSetSubscriptionInput!) { - updateWorkItemNotificationsSubscription: issueSetSubscription(input: $input) { - issue { +mutation workItemSubscribe($input: WorkItemSubscribeInput!) { + workItemSubscribe(input: $input) { + errors + workItem { id - subscribed + widgets { + ... on WorkItemWidgetNotifications { + type + subscribed + } + } } - errors } } diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql index 1ae5617f04d..fac99310890 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -33,6 +33,7 @@ fragment WorkItem on WorkItem { adminParentLink setWorkItemMetadata createNote + adminWorkItemLink } widgets { ...WorkItemWidgets 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 index f303a797e9c..d15e3086560 100644 --- 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 @@ -52,4 +52,12 @@ fragment WorkItemMetadataWidgets on WorkItemWidget { ... on WorkItemWidgetAwardEmoji { type } + + ... on WorkItemWidgetLinkedItems { + type + } + + ... on WorkItemWidgetHierarchy { + type + } } 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 index b4fb83b24c2..5c797367903 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql @@ -37,6 +37,7 @@ query workItemTreeQuery($id: WorkItemID!) { state createdAt closedAt + webUrl widgets { ... on WorkItemWidgetHierarchy { type 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 ffc9fe2f7f7..b357e765d16 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 @@ -66,6 +66,7 @@ fragment WorkItemWidgets on WorkItemWidget { state createdAt closedAt + webUrl widgets { ... on WorkItemWidgetHierarchy { type @@ -120,6 +121,7 @@ fragment WorkItemWidgets on WorkItemWidget { state createdAt closedAt + webUrl widgets { ...WorkItemMetadataWidgets } diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index 70bda7d3783..0b7f9290d6e 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -1,17 +1,25 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { WORKSPACE_GROUP } from '~/issues/constants'; import { parseBoolean } from '~/lib/utils/common_utils'; import { apolloProvider } from '~/graphql_shared/issuable_client'; import App from './components/app.vue'; +import WorkItemRoot from './pages/work_item_root.vue'; import { createRouter } from './router'; Vue.use(VueApollo); -export const initWorkItemsRoot = () => { +export const initWorkItemsRoot = (workspace) => { const el = document.querySelector('#js-work-items'); + + if (!el) { + return undefined; + } + const { fullPath, hasIssueWeightsFeature, + iid, issuesListPath, registerPath, signInPath, @@ -22,6 +30,8 @@ export const initWorkItemsRoot = () => { reportAbusePath, } = el.dataset; + const Component = workspace === WORKSPACE_GROUP ? WorkItemRoot : App; + return new Vue({ el, name: 'WorkItemsRoot', @@ -29,6 +39,7 @@ export const initWorkItemsRoot = () => { apolloProvider, provide: { fullPath, + isGroup: workspace === WORKSPACE_GROUP, hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), hasOkrsFeature: parseBoolean(hasOkrsFeature), issuesListPath, @@ -40,7 +51,11 @@ export const initWorkItemsRoot = () => { reportAbusePath, }, render(createElement) { - return createElement(App); + return createElement(Component, { + props: { + iid: workspace === WORKSPACE_GROUP ? iid : undefined, + }, + }); }, }); }; diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue index b5705b21b5a..31e790254d9 100644 --- a/app/assets/javascripts/work_items/pages/create_work_item.vue +++ b/app/assets/javascripts/work_items/pages/create_work_item.vue @@ -10,6 +10,7 @@ import { } from '../constants'; import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql'; import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql'; +import groupWorkItemByIidQuery from '../graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; import ItemTitle from '../components/item_title.vue'; @@ -22,7 +23,7 @@ export default { ItemTitle, GlFormSelect, }, - inject: ['fullPath'], + inject: ['fullPath', 'isGroup'], props: { initialTitle: { type: String, @@ -94,7 +95,7 @@ export default { const { workItem } = workItemCreate; store.writeQuery({ - query: workItemByIidQuery, + query: this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery, variables: { fullPath: this.fullPath, iid: workItem.iid, diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js index 1443e4b509d..ac5d8b32fad 100644 --- a/app/assets/javascripts/work_items/utils.js +++ b/app/assets/javascripts/work_items/utils.js @@ -1,4 +1,3 @@ -import { joinPaths } from '~/lib/utils/url_utility'; import { WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_HEALTH_STATUS, @@ -43,7 +42,3 @@ export const markdownPreviewPath = (fullPath, iid) => `${ gon.relative_url_root || '' }/${fullPath}/preview_markdown?target_type=WorkItem&target_id=${iid}`; - -export const workItemPath = (fullPath, workItemIid) => { - return joinPaths(gon?.relative_url_root || '/', fullPath, '-', 'work_items', workItemIid); -}; diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index 47701d0490a..be9a06d7bb5 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -4,11 +4,9 @@ @import './pages/groups'; @import './pages/hierarchy'; @import './pages/issues'; -@import './pages/labels'; @import './pages/note_form'; @import './pages/notes'; @import './pages/pipelines'; @import './pages/profile'; -@import './pages/projects'; @import './pages/registry'; @import './pages/settings'; diff --git a/app/assets/stylesheets/components/detail_page.scss b/app/assets/stylesheets/components/detail_page.scss index 74f61faa9ae..de8142924f9 100644 --- a/app/assets/stylesheets/components/detail_page.scss +++ b/app/assets/stylesheets/components/detail_page.scss @@ -74,7 +74,3 @@ color: $gl-text-color; } } - -.new-header-popover { - z-index: 999; -} diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss index 04a7590d531..4d53ae9ed4b 100644 --- a/app/assets/stylesheets/components/related_items_list.scss +++ b/app/assets/stylesheets/components/related_items_list.scss @@ -5,7 +5,6 @@ $item-remove-button-space: 42px; .related-items-list { padding: $gl-padding-4; - padding-right: $gl-padding-6; border-bottom-left-radius: $gl-border-size-3; border-bottom-right-radius: $gl-border-size-3; diff --git a/app/assets/stylesheets/fonts.scss b/app/assets/stylesheets/fonts.scss index 0d87d49ac18..6886e751b72 100644 --- a/app/assets/stylesheets/fonts.scss +++ b/app/assets/stylesheets/fonts.scss @@ -7,7 +7,7 @@ Usage: @font-face { font-family: 'GitLab Sans'; font-weight: 100 900; - font-display: optional; + font-display: swap; font-style: normal; /* stylelint-disable-next-line property-no-unknown */ font-named-instance: 'Regular'; @@ -17,7 +17,7 @@ Usage: @font-face { font-family: 'GitLab Sans'; font-weight: 100 900; - font-display: optional; + font-display: swap; font-style: italic; /* stylelint-disable-next-line property-no-unknown */ font-named-instance: 'Regular'; @@ -33,7 +33,7 @@ Usage: @font-face { font-family: 'GitLab Mono'; font-weight: 100 900; - font-display: optional; + font-display: swap; font-style: normal; src: font-url('gitlab-mono/GitLabMono.woff2') format('woff2'); } @@ -41,7 +41,7 @@ Usage: @font-face { font-family: 'GitLab Mono'; font-weight: 100 900; - font-display: optional; + font-display: swap; font-style: italic; src: font-url('gitlab-mono/GitLabMono-Italic.woff2') format('woff2'); } diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index cbdc55d66c1..cae2ea1716c 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -192,3 +192,7 @@ padding: inherit; } } + +.gl-empty-state { + margin-top: $gl-spacing-scale-7; +} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 514247d2913..21c252038af 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -43,7 +43,8 @@ .right-sidebar-collapsed { --application-bar-right: #{$right-sidebar-collapsed-width}; - &.is-merge-request { + &.is-merge-request, + &.build-sidebar { --application-bar-right: 0px; } } @@ -51,6 +52,10 @@ .right-sidebar-expanded { --application-bar-right: #{$right-sidebar-width}; } + + .build-sidebar { + --application-bar-right: 0px; + } } @include media-breakpoint-up(md) { @@ -567,3 +572,43 @@ See https://gitlab.com/gitlab-org/gitlab/issues/36857 for more details. } } } + +// --- moved from labels.scss when moving to page_bundles --- +// Fix scoped label padding in cases where old markdown uses the old label structure */ +.gl-label-text + .gl-label-text { + @include gl-pl-2; + @include gl-pr-3; +} + +// used in the Markdown rendering of labels +.scoped-label-tooltip-title { + color: var(--indigo-300, $indigo-300); +} + +.gl-label-scoped { + box-shadow: 0 0 0 2px currentColor inset; + + &.gl-label-sm { + box-shadow: 0 0 0 1px inset; + } +} + +.ref-container, +.commit-sha-container { + font-family: $gl-monospace-font; + font-variant-ligatures: none; + font-size: $gl-font-size-sm; + padding-left: $gl-spacing-scale-2; + padding-right: $gl-spacing-scale-2; + border-radius: $gl-border-radius-base; +} + +.ref-container { + color: var(--blue-500, $blue-500) !important; + background-color: var(--blue-50, $blue-50); +} + +.commit-sha-container { + color: var(--gray-700, $gray-700) !important; + background-color: var(--gray-50, $gray-50); +} diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss index 9b22e4cebb2..d3986f31d52 100644 --- a/app/assets/stylesheets/framework/emojis.scss +++ b/app/assets/stylesheets/framework/emojis.scss @@ -63,6 +63,7 @@ gl-emoji { border-bottom-color: $blue-500; } -.emoji-picker .gl-dropdown-inner > :last-child { +.emoji-picker .gl-dropdown-contents > :last-child { padding-bottom: 0; + overflow-y: hidden; } diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index a32663b17d3..df107798a87 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -254,7 +254,7 @@ @mixin build-log-bar($height) { height: $height; min-height: $height; - background: var(--gray-50, $gray-50); + background: var(--white, $white); border: 1px solid var(--border-color, $border-color); color: var(--gl-text-color, $gl-text-color); padding: $grid-size; diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index f2afa94e000..0619d5f166e 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -55,6 +55,10 @@ padding-right: 0; z-index: $zindex-dropdown-menu; + .inline-block { + @include gl-display-inline-block; + } + &.right-sidebar-merge-requests { width: $right-sidebar-width; @@ -73,13 +77,21 @@ } } - &:not(.is-merge-request) { + &:not(.is-merge-request):not(.build-sidebar) { @include media-breakpoint-up(md) { .content-wrapper { padding-right: $right-sidebar-width; } } } + + &.build-sidebar { + @include media-breakpoint-up(lg) { + .content-wrapper { + padding-right: $right-sidebar-width; + } + } + } } .right-sidebar { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index e83f6af603a..a4bb39e0764 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -481,7 +481,7 @@ $count-arrow-border: #dce0e5; $general-hover-transition-duration: 100ms; $general-hover-transition-curve: linear; $highlight-changes-color: rgb(235, 255, 232); -$performance-bar-height: 35px; +$performance-bar-height: 2.5rem; $system-header-height: 16px; $system-footer-height: $system-header-height; $mr-sticky-header-height: 72px; @@ -834,7 +834,7 @@ Performance Bar */ $perf-bar-production: $gray-950; $perf-bar-staging: $indigo-950; -$perf-bar-development: $red-950; +$perf-bar-development: $red-900; $perf-bar-bucket-bg: $black; $perf-bar-bucket-box-shadow-from: rgba($white, 0.2); $perf-bar-bucket-box-shadow-to: rgba($black, 0.25); diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss index 09c4d184f3f..16fc0e7ebae 100644 --- a/app/assets/stylesheets/page_bundles/build.scss +++ b/app/assets/stylesheets/page_bundles/build.scss @@ -17,7 +17,7 @@ @include build-log-top-bar(50px); z-index: 2; border-radius: $border-radius-default $border-radius-default 0 0; - box-shadow: 0 -2px 0 0 var(--white); + box-shadow: 0 -4px 0 0 var(--white); &.has-archived-block { top: calc(#{$calc-application-header-height} + 28px); @@ -89,13 +89,20 @@ } .right-sidebar.build-sidebar { + padding: 0; + + @include media-breakpoint-up(lg) { + @include gl-border-l-0; + } + &.right-sidebar-collapsed { display: none; } .sidebar-container { - padding-right: 100px; - height: 100%; + @include gl-sticky; + top: #{$top-bar-height - 1px}; + max-height: calc(100vh - #{$top-bar-height - 1px} - var(--performance-bar-height)); overflow-y: scroll; overflow-x: hidden; -webkit-overflow-scrolling: touch; @@ -155,10 +162,6 @@ } .build-sidebar-item { - display: grid; - grid-template-columns: 1fr 2fr; - grid-gap: $gl-padding-8; - &:last-of-type { @include gl-mb-0; } diff --git a/app/assets/stylesheets/page_bundles/escalation_policies.scss b/app/assets/stylesheets/page_bundles/escalation_policies.scss index 84c62ba93dd..49423fccea4 100644 --- a/app/assets/stylesheets/page_bundles/escalation_policies.scss +++ b/app/assets/stylesheets/page_bundles/escalation_policies.scss @@ -42,3 +42,9 @@ $stroke-size: 1px; @include gl-w-full; } } + +.escalation-email-user-dropdown { + .show.dropdown .dropdown-menu { + max-height: 300px; + } +} diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 2002b4d4dff..7f8068e5d56 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -918,7 +918,7 @@ $ide-commit-header-height: 48px; --svg-status-bg: var(--ide-background, #{$white}); } - .empty-state { + .gl-empty-state { p { margin: $grid-size 0; text-align: center; diff --git a/app/assets/stylesheets/page_bundles/issuable.scss b/app/assets/stylesheets/page_bundles/issuable.scss index 5397f3d8895..07614c5271a 100644 --- a/app/assets/stylesheets/page_bundles/issuable.scss +++ b/app/assets/stylesheets/page_bundles/issuable.scss @@ -1,18 +1,5 @@ @import 'mixins_and_variables_and_functions'; -$issuable-warning-size: 24px; - -.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; - line-height: $gl-line-height-24; - flex: 0 0 auto; -} - .limit-container-width { .flash-container, .detail-page-header, diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/page_bundles/labels.scss index 29f2d15008b..bc0bf4bc490 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/page_bundles/labels.scss @@ -1,3 +1,5 @@ +@import 'mixins_and_variables_and_functions'; + .suggest-colors { padding-top: 3px; @@ -29,19 +31,19 @@ margin-bottom: -5px; &:first-of-type { - border-top-left-radius: $border-radius-base; + border-top-left-radius: $gl-border-radius-base; } &:nth-of-type(7) { - border-top-right-radius: $border-radius-base; + border-top-right-radius: $gl-border-radius-base; } &:nth-last-child(7) { - border-bottom-left-radius: $border-radius-base; + border-bottom-left-radius: $gl-border-radius-base; } &:last-of-type { - border-bottom-right-radius: $border-radius-base; + border-bottom-right-radius: $gl-border-radius-base; } } } @@ -78,7 +80,7 @@ padding: 0 $grid-size; line-height: 16px; border-radius: $label-border-radius; - color: $white; + color: var(--white, $white); } .manage-labels-list { @@ -94,7 +96,7 @@ &:hover, &:focus-within { - background-color: $blue-50; + background-color: var(--blue-50, $blue-50); } &:active { @@ -109,10 +111,6 @@ } } -.label-list-item:not(:last-of-type) { - border-bottom: 1px solid $border-color; -} - .prioritized-labels .add-priority, .other-labels .remove-priority { display: none; @@ -133,7 +131,7 @@ } .label-badge { - color: $gray-900; + color: var(--gray-900, $gray-900); display: inline-block; font-weight: $gl-font-weight-normal; padding: $gl-padding-4 $gl-padding-8; @@ -151,15 +149,15 @@ } .label-action { - color: $gray-700; + color: var(--gray-700, $gray-700); cursor: pointer; &:hover { - color: $blue-600; + color: var(--blue-600, $blue-600); } &.hover-red:hover { - color: $red-500; + color: var(--red-500, $red-500); } } } @@ -192,21 +190,3 @@ .priority-labels-empty-state .svg-content img { max-width: $priority-label-empty-state-width; } - -.scoped-label-tooltip-title { - color: $indigo-300; -} - -.gl-label-scoped { - box-shadow: 0 0 0 2px currentColor inset; - - &.gl-label-sm { - box-shadow: 0 0 0 1px inset; - } -} - -/* Fix scoped label padding in cases where old markdown uses the old label structure */ -.gl-label-text + .gl-label-text { - @include gl-pl-2; - @include gl-pr-3; -} diff --git a/app/assets/stylesheets/page_bundles/merge_request.scss b/app/assets/stylesheets/page_bundles/merge_request.scss index f03efb82860..e429c0c149e 100644 --- a/app/assets/stylesheets/page_bundles/merge_request.scss +++ b/app/assets/stylesheets/page_bundles/merge_request.scss @@ -205,7 +205,11 @@ $comparison-empty-state-height: 62px; top: $calc-application-header-height; z-index: $tabs-holder-z-index; border-bottom: 1px solid var(--border-color, $border-color); - background-color: var(--gray-10, $white); + background-color: $white; + + .gl-dark & { + background-color: var(--gray-10); + } @include media-breakpoint-up(md) { position: sticky; diff --git a/app/assets/stylesheets/page_bundles/ml_experiment_tracking.scss b/app/assets/stylesheets/page_bundles/ml_experiment_tracking.scss index d6f71b12cd9..685719071b5 100644 --- a/app/assets/stylesheets/page_bundles/ml_experiment_tracking.scss +++ b/app/assets/stylesheets/page_bundles/ml_experiment_tracking.scss @@ -15,6 +15,6 @@ table.ml-candidate-table { table.candidate-details { td { - padding: $gl-spacing-scale-3; + padding: $gl-spacing-scale-3 $gl-spacing-scale-3 $gl-spacing-scale-3 0; } } diff --git a/app/assets/stylesheets/page_bundles/organizations.scss b/app/assets/stylesheets/page_bundles/organizations.scss new file mode 100644 index 00000000000..1f1d127a82a --- /dev/null +++ b/app/assets/stylesheets/page_bundles/organizations.scss @@ -0,0 +1,10 @@ +@import 'mixins_and_variables_and_functions'; + +// Modeled after projects.scss and groups.scss +.organization-row .organization-description p { + @include gl-mb-0; +} + +.organization-root-path { + max-width: 40vw; +} diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/page_bundles/projects.scss index 9ce470dbcf2..99c84026762 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/page_bundles/projects.scss @@ -1,3 +1,5 @@ +@import 'mixins_and_variables_and_functions'; + .new_project, .edit-project, .import-project { @@ -6,7 +8,7 @@ } .project-path .form-control { - border-radius: $border-radius-base; + border-radius: $gl-border-radius-base; } .input-group { @@ -41,13 +43,13 @@ &.static-namespace { height: 35px; border-radius: 3px; - border: 1px solid $border-color; + border: 1px solid var(--border-color, $border-color); max-width: 100%; flex-grow: 1; } + .btn-default { - border-radius: 0 $border-radius-base $border-radius-base 0; + border-radius: 0 $gl-border-radius-base $gl-border-radius-base 0; } } } @@ -55,7 +57,7 @@ .save-project-loader { margin-top: 50px; margin-bottom: 50px; - color: $gray-700; + color: var(--gray-700, $gray-700); } .deploy-key { @@ -85,23 +87,23 @@ } .vs-public { - color: $blue-500; + color: var(--blue-500, $blue-500); } .vs-internal { - color: $orange-500; + color: var(--orange-500, $orange-500); } .vs-private { - color: $green-500; + color: var(--green-500, $green-500); } .lfs-enabled { - color: $green-500; + color: var(--green-500, $green-500); } .lfs-disabled { - color: $orange-500; + color: var(--orange-500, $orange-500); } .breadcrumb.repo-breadcrumb { @@ -113,7 +115,7 @@ margin: 0; a { - color: $gl-text-color; + color: var(--gl-text-color, $gl-text-color); } .dropdown-menu { @@ -135,7 +137,7 @@ padding: 16px 0; &:not(:first-child) { - border-top: 1px solid $border-color; + border-top: 1px solid var(--border-color, $border-color); } .controls { @@ -166,7 +168,7 @@ .input-group-text { width: 100%; - background-color: $white; + background-color: var(--white, $white); } .selected-icon { @@ -235,7 +237,7 @@ .repository-languages-bar { height: 8px; margin-bottom: $gl-padding; - background-color: $white; + background-color: var(--white, $white); border-radius: $border-radius-default; .progress-bar { @@ -253,7 +255,7 @@ } .repository-language-bar-tooltip-share { - color: $gray-200; + color: var(--gray-200, $gray-200); } /* @@ -263,7 +265,7 @@ .project-row { .description p { margin-bottom: 0; - color: $gl-text-color-secondary; + color: var(--gl-text-color-secondary, $gl-text-color-secondary); @include str-truncated(100%); } } @@ -280,7 +282,7 @@ @include gl-display-table-cell; @include gl-vertical-align-top; @include gl-py-4; - border-bottom: 1px solid $gray-50; + border-bottom: 1px solid var(--gray-50, $gray-50); } .project-row:last-of-type { @@ -470,8 +472,8 @@ .form-control { @include gl-font-monospace; - background-color: $white; - border-color: $border-color; + background-color: var(--white, $white); + border-color: var(--border-color, $border-color); font-size: 14px; margin-left: -1px; cursor: auto; diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss index f36cbc129a7..01c6fde80da 100644 --- a/app/assets/stylesheets/page_bundles/work_items.scss +++ b/app/assets/stylesheets/page_bundles/work_items.scss @@ -93,6 +93,29 @@ $work-item-sticky-header-height: 52px; } } + // need to override the listbox styles to match with dropdown + // till the dropdown are converted to listbox + .gl-new-dropdown-toggle { + &:hover, + &:focus { + background: none !important; + box-shadow: $work-item-field-inset-shadow; + background-color: $input-bg; + } + + .is-not-focused { + &.gl-new-dropdown-button-text { + margin: 0 0.25rem; + } + } + } + + .gl-new-dropdown-toggle.is-not-focused { + .gl-new-dropdown-button-text { + margin: 0 0.25rem; + } + } + > .col { min-width: 0; } @@ -167,6 +190,12 @@ $work-item-sticky-header-height: 52px; } } +.work-item-parent-field-value { + .work-item-overview & { + max-width: 75%; + } +} + .token-selector-menu-class { .work-item-overview & { width: 100%; diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss index cb153122767..490ac15241b 100644 --- a/app/assets/stylesheets/performance_bar.scss +++ b/app/assets/stylesheets/performance_bar.scss @@ -10,8 +10,9 @@ height: $performance-bar-height; background: $black; + font-size: $gl-font-size-small; line-height: $performance-bar-height; - color: $gray-100; + color: $gray-50; select { width: 200px; @@ -24,13 +25,22 @@ select, input { color: inherit; - background-color: inherit; + background-color: rgba($white, 0.2); + + &::placeholder { + color: rgba($white, 0.7); + } } option { color: initial; } + .gl-link, + .gl-button { + color: $white; + } + &.disabled { display: none; } @@ -45,6 +55,10 @@ &.development { background-color: $perf-bar-development; + + .gl-dark & { + background-color: $red-950; + } } // UI Elements @@ -88,7 +102,6 @@ } .view { - margin-right: 15px; flex-shrink: 0; &:last-child { @@ -96,6 +109,22 @@ } } + .view-performance-container, + .view-reports-container { + margin-right: $gl-padding-24; + + .view:not(:first-child) { + margin-right: 0; + + &::before { + content: '•'; + opacity: .5; + display: inline-block; + margin: 0 $gl-padding-8; + } + } + } + .css-truncate { &.css-truncate-target, .css-truncate-target { diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss index 7616f573412..73877c04c46 100644 --- a/app/assets/stylesheets/themes/dark_mode_overrides.scss +++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss @@ -164,7 +164,7 @@ body.gl-dark { // rendered and cached in the backend (labels_helper.rb) &.gl-label-scoped { .gl-label-text-scoped, - .gl-label-close { + .gl-label-close.gl-button .gl-icon { color: $gray-900; } } @@ -172,18 +172,24 @@ body.gl-dark { // white-ish text for light labels .gl-label-text-light.gl-label-text-light { - color: $gray-900; + &, + .gl-label-close .gl-icon { + color: $gray-900; + } } .gl-label-text-dark.gl-label-text-dark { - color: $gray-10; + &, + .gl-label-close .gl-icon { + color: $gray-10; + } } // This applies to "gl-labels" from "gitlab-ui" .gl-label.gl-label-scoped.gl-label-text-dark, .gl-label.gl-label-scoped.gl-label-text-light { .gl-label-text-scoped, - .gl-label-close { + .gl-label-close.gl-button .gl-icon { color: $gray-900; } } diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index b756e0ed704..8fe45d4bb9d 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -135,3 +135,7 @@ .gl-hover-border-gray-100:hover { border-color: $gray-100; } + +.gl-last-of-type-border-b-0:last-of-type { + @include gl-border-b-0; +} diff --git a/app/components/pajamas/alert_component.html.haml b/app/components/pajamas/alert_component.html.haml index a7be57311bb..ee7d5552455 100644 --- a/app/components/pajamas/alert_component.html.haml +++ b/app/components/pajamas/alert_component.html.haml @@ -1,6 +1,7 @@ .gl-alert{ @alert_options, role: 'alert', class: base_class } - if @show_icon - = sprite_icon(icon, css_class: icon_classes) + .gl-alert-icon-container + = sprite_icon(icon, css_class: icon_classes) - if @dismissible = render Pajamas::ButtonComponent.new(category: :tertiary, icon: 'close', @@ -8,7 +9,7 @@ button_options: dismissible_button_options) .gl-alert-content{ role: 'alert' } - if @title - %h4.gl-alert-title + %h2.gl-alert-title = @title - if body? .gl-alert-body diff --git a/app/components/pajamas/alert_component.rb b/app/components/pajamas/alert_component.rb index 008d624b7e2..c9397ca56cc 100644 --- a/app/components/pajamas/alert_component.rb +++ b/app/components/pajamas/alert_component.rb @@ -24,7 +24,7 @@ module Pajamas classes = ["gl-alert-#{@variant}"] classes.push('gl-alert-not-dismissible') unless @dismissible classes.push('gl-alert-no-icon') unless @show_icon - + classes.push('gl-alert-has-title') if @title classes.join(' ') end diff --git a/app/components/pajamas/banner_component.html.haml b/app/components/pajamas/banner_component.html.haml index 8a177edddb5..ebb88b305dc 100644 --- a/app/components/pajamas/banner_component.html.haml +++ b/app/components/pajamas/banner_component.html.haml @@ -1,22 +1,26 @@ -%section.gl-banner{ @banner_options, class: banner_class } - - if illustration? - .gl-banner-illustration - = illustration - - elsif @svg_path.present? - .gl-banner-illustration - = image_tag @svg_path, alt: "" +-# This is using gl-card classes to match Vue component +-# Here's the issue to refactor away from gl-card +-# https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2324 +.gl-banner.gl-card.gl-pl-6.gl-pr-8.gl-py-6{ @banner_options, class: banner_class } + .gl-display-flex + - if illustration? + .gl-banner-illustration + = illustration + - elsif @svg_path.present? + .gl-banner-illustration + = image_tag @svg_path, alt: "" - .gl-banner-content - %h1.gl-banner-title= title + .gl-banner-content + %h1.gl-banner-title= title - = content + = content - - if primary_action? - = primary_action - - else - = link_button_to @button_text, @button_link, **@button_options, class: 'js-close-callout', variant: :confirm + - if primary_action? + = primary_action + - else + = link_button_to @button_text, @button_link, **@button_options, class: 'js-close-callout', variant: :confirm - - actions.each do |action| - = action + - actions.each do |action| + = action = render Pajamas::ButtonComponent.new(category: :tertiary, variant: close_button_variant, size: :small, icon: 'close', button_options: @close_options) diff --git a/app/components/pajamas/empty_state_component.html.haml b/app/components/pajamas/empty_state_component.html.haml index ecd3498c5cd..d7af153db2c 100644 --- a/app/components/pajamas/empty_state_component.html.haml +++ b/app/components/pajamas/empty_state_component.html.haml @@ -1,24 +1,24 @@ -- empty_state_class = @compact ? 'gl-flex-direction-row gl-align-items-center' : 'gl-text-center gl-flex-direction-column' +- empty_state_class = @compact ? 'gl-flex-direction-row' : 'gl-text-center gl-flex-direction-column' -%section.gl-display-flex.empty-state{ **@empty_state_options, class: empty_state_class } +%section.gl-display-flex.gl-empty-state{ **@empty_state_options, class: empty_state_class } - if @svg_path.present? - image_class = @compact ? 'gl-display-none gl-sm-display-block gl-px-4' : 'gl-max-w-full' %div{ class: image_class } = image_tag @svg_path, alt: "", class: 'gl-dark-invert-keep-hue' - - content_wrapper_class = @compact ? 'gl-flex-grow-1 gl-flex-basis-0 gl-px-4' : 'gl-max-w-full gl-m-auto pl-p-5' - %div{ class: content_wrapper_class } - - title_class = @compact ? 'gl-mt-0' : 'gl-my-3' - %h1.gl-font-size-h-display.gl-line-height-36{ class: title_class } + - content_wrapper_class = @compact ? 'gl-flex-grow-1 gl-flex-basis-0 gl-px-4' : 'gl-m-auto gl-p-5' + .gl-empty-state-content.gl-mx-auto.gl-my-0{ class: content_wrapper_class } + - title_class = @compact ? 'h5' : 'h4' + %h1.gl-font-size-h-display.gl-line-height-36.gl-mt-0.gl-mb-0{ class: title_class } = @title - if description? - %p.gl-mt-3{ 'data-testid': 'empty-state-description' } + %p.gl-mt-4.gl-mb-0{ 'data-testid': 'empty-state-description' } = description - if @primary_button_text.present? || @secondary_button_text.present? - button_wrapper_class = @compact.present? ? '' : 'gl-justify-content-center' - .gl-display-flex.gl-flex-wrap{ class: button_wrapper_class } + .gl-display-flex.gl-flex-wrap.gl-mt-5.gl-gap-3{ class: button_wrapper_class } - if @primary_button_text.present? = render Pajamas::ButtonComponent.new(variant: :confirm, href: @primary_button_link, button_options: { class: 'gl-ml-0!' }) do diff --git a/app/components/projects/ml/models_index_component.rb b/app/components/projects/ml/models_index_component.rb index c5c20565195..57900165ad1 100644 --- a/app/components/projects/ml/models_index_component.rb +++ b/app/components/projects/ml/models_index_component.rb @@ -3,27 +3,42 @@ module Projects module Ml class ModelsIndexComponent < ViewComponent::Base - attr_reader :models + attr_reader :paginator - def initialize(models:) - @models = models + def initialize(paginator:) + @paginator = paginator end private def view_model - Gitlab::Json.generate({ models: models_view_model }) + vm = { + models: models_view_model, + page_info: page_info_view_model + } + + Gitlab::Json.generate(vm.deep_transform_keys { |k| k.to_s.camelize(:lower) }) end def models_view_model - models.map(&:present).map do |m| + paginator.records.map(&:present).map do |m| { name: m.name, version: m.latest_version_name, + version_count: m.version_count, path: m.latest_package_path } end end + + def page_info_view_model + { + has_next_page: paginator.has_next_page?, + has_previous_page: paginator.has_previous_page?, + start_cursor: paginator.cursor_for_previous_page, + end_cursor: paginator.cursor_for_next_page + } + end end end end diff --git a/app/components/projects/ml/show_ml_model_component.html.haml b/app/components/projects/ml/show_ml_model_component.html.haml new file mode 100644 index 00000000000..20e52246e6d --- /dev/null +++ b/app/components/projects/ml/show_ml_model_component.html.haml @@ -0,0 +1 @@ +#js-mount-show-ml-model{ data: { view_model: view_model } } diff --git a/app/components/projects/ml/show_ml_model_component.rb b/app/components/projects/ml/show_ml_model_component.rb new file mode 100644 index 00000000000..2fe2c7e7e9d --- /dev/null +++ b/app/components/projects/ml/show_ml_model_component.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Projects + module Ml + class ShowMlModelComponent < ViewComponent::Base + attr_reader :model + + def initialize(model:) + @model = model.present + end + + private + + def view_model + vm = { + model: { + id: model.id, + name: model.name, + path: model.path + } + } + + Gitlab::Json.generate(vm) + end + end + end +end diff --git a/app/controllers/acme_challenges_controller.rb b/app/controllers/acme_challenges_controller.rb index 4a7706db94e..a187e43b3df 100644 --- a/app/controllers/acme_challenges_controller.rb +++ b/app/controllers/acme_challenges_controller.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true -# rubocop:disable Rails/ApplicationController -class AcmeChallengesController < ActionController::Base +class AcmeChallengesController < BaseActionController def show if acme_order render plain: acme_order.challenge_file_content, content_type: 'text/plain' @@ -16,4 +15,3 @@ class AcmeChallengesController < ActionController::Base @acme_order ||= PagesDomainAcmeOrder.find_by_domain_and_token(params[:domain], params[:token]) end end -# rubocop:enable Rails/ApplicationController diff --git a/app/controllers/admin/identities_controller.rb b/app/controllers/admin/identities_controller.rb index 15c4103a781..17b0adb868e 100644 --- a/app/controllers/admin/identities_controller.rb +++ b/app/controllers/admin/identities_controller.rb @@ -63,6 +63,6 @@ class Admin::IdentitiesController < Admin::ApplicationController end def identity_params - params.require(:identity).permit(:provider, :extern_uid) + params.require(:identity).permit(:provider, :extern_uid, :saml_provider_id) end end diff --git a/app/controllers/admin/topics_controller.rb b/app/controllers/admin/topics_controller.rb index c4de600dd1d..40cad2d26f4 100644 --- a/app/controllers/admin/topics_controller.rb +++ b/app/controllers/admin/topics_controller.rb @@ -8,10 +8,6 @@ class Admin::TopicsController < Admin::ApplicationController feature_category :groups_and_projects - before_action do - push_frontend_feature_flag(:content_editor_on_issues, current_user) - end - def index @topics = Projects::TopicsFinder.new(params: params.permit(:search)).execute.page(params[:page]).without_count end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7c69f43fa3d..f60da46826a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,7 +3,7 @@ require 'gon' require 'fogbugz' -class ApplicationController < ActionController::Base +class ApplicationController < BaseActionController include Gitlab::GonHelper include Gitlab::NoCacheHeaders include GitlabRoutingHelper @@ -24,6 +24,7 @@ class ApplicationController < ActionController::Base include ::Gitlab::EndpointAttributes include FlocOptOut include CheckRateLimit + include RequestPayloadLogger extend ContentSecurityPolicyPatch before_action :limit_session_time, if: -> { !current_user } @@ -180,29 +181,6 @@ class ApplicationController < ActionController::Base @workhorse_excluded_content_types ||= %w[text/html application/json] end - def append_info_to_payload(payload) - super - - payload[:ua] = request.env["HTTP_USER_AGENT"] - payload[:remote_ip] = request.remote_ip - - payload[Labkit::Correlation::CorrelationId::LOG_KEY] = Labkit::Correlation::CorrelationId.current_id - payload[:metadata] = @current_context - payload[:request_urgency] = urgency&.name - payload[:target_duration_s] = urgency&.duration - logged_user = auth_user - if logged_user.present? - payload[:user_id] = logged_user.try(:id) - payload[:username] = logged_user.try(:username) - end - - payload[:queue_duration_s] = request.env[::Gitlab::Middleware::RailsQueueDuration::GITLAB_RAILS_QUEUE_DURATION_KEY] - - payload[:response_bytes] = response.body_parts.sum(&:bytesize) if Feature.enabled?(:log_response_length) - - store_cloudflare_headers!(payload, request) - end - ## # Controllers such as GitHttpController may use alternative methods # (e.g. tokens) to authenticate the user, whereas Devise sets current_user. diff --git a/app/controllers/base_action_controller.rb b/app/controllers/base_action_controller.rb new file mode 100644 index 00000000000..af2c9e98778 --- /dev/null +++ b/app/controllers/base_action_controller.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# GitLab lightweight base action controller +# +# This class should be limited to content that +# is desired/required for *all* controllers in +# GitLab. +# +# Most controllers inherit from `ApplicationController`. +# Some controllers don't want or need all of that +# logic and instead inherit from `ActionController::Base`. +# This makes it difficult to set security headers and +# handle other critical logic across *all* controllers. +# +# Between this controller and `ApplicationController` +# no controller should ever inherit directly from +# `ActionController::Base` +# +# rubocop:disable Rails/ApplicationController +# rubocop:disable Gitlab/NamespacedClass +class BaseActionController < ActionController::Base + before_action :security_headers + + private + + def security_headers + headers['Cross-Origin-Opener-Policy'] = 'same-origin' if ::Feature.enabled?(:coop_header) + end +end +# rubocop:enable Gitlab/NamespacedClass +# rubocop:enable Rails/ApplicationController diff --git a/app/controllers/chaos_controller.rb b/app/controllers/chaos_controller.rb index 7328b793b09..b61a8c5ff12 100644 --- a/app/controllers/chaos_controller.rb +++ b/app/controllers/chaos_controller.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true -# rubocop:disable Rails/ApplicationController -class ChaosController < ActionController::Base +class ChaosController < BaseActionController before_action :validate_chaos_secret, unless: :development_or_test? def leakmem @@ -95,4 +94,3 @@ class ChaosController < ActionController::Base Rails.env.development? || Rails.env.test? end end -# rubocop:enable Rails/ApplicationController diff --git a/app/controllers/concerns/access_tokens_actions.rb b/app/controllers/concerns/access_tokens_actions.rb index de53fd4d835..84cbdda1581 100644 --- a/app/controllers/concerns/access_tokens_actions.rb +++ b/app/controllers/concerns/access_tokens_actions.rb @@ -69,7 +69,6 @@ module AccessTokensActions resource.members.load @scopes = Gitlab::Auth.available_scopes_for(resource) - @scopes.delete(Gitlab::Auth::K8S_PROXY_SCOPE) unless Feature.enabled?(:k8s_proxy_pat, current_user) @active_access_tokens = active_access_tokens end # rubocop:enable Gitlab/ModuleWithInstanceVariables diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb index 896004045f4..27f1d1f5528 100644 --- a/app/controllers/concerns/creates_commit.rb +++ b/app/controllers/concerns/creates_commit.rb @@ -10,18 +10,18 @@ module CreatesCommit if user_access(target_project).can_push_to_branch?(branch_name_or_ref) @project_to_commit_into = target_project + @different_project = false @branch_name ||= @ref else @project_to_commit_into = current_user.fork_of(target_project) + @different_project = true @branch_name ||= @project_to_commit_into.repository.next_branch('patch') end @start_branch ||= @ref || @branch_name - start_project = @project_to_commit_into - commit_params = @commit_params.merge( - start_project: start_project, + start_project: @project_to_commit_into, start_branch: @start_branch, source_project: @project, target_project: target_project, @@ -74,7 +74,7 @@ module CreatesCommit nil else mr_message = - if different_project? + if @different_project # rubocop:disable Gitlab/ModuleWithInstanceVariables _("You can now submit a merge request to get this change into the original project.") else _("You can now submit a merge request to get this change into the original branch.") @@ -128,16 +128,12 @@ module CreatesCommit # rubocop: enable CodeReuse/ActiveRecord # rubocop:enable Gitlab/ModuleWithInstanceVariables - def different_project? - @project_to_commit_into != @project # rubocop:disable Gitlab/ModuleWithInstanceVariables - end - def create_merge_request? # Even if the field is set, if we're checking the same branch # as the target branch in the same project, # we don't want to create a merge request. params[:create_merge_request].present? && - (different_project? || @start_branch != @branch_name) # rubocop:disable Gitlab/ModuleWithInstanceVariables + (@different_project || @start_branch != @branch_name) # rubocop:disable Gitlab/ModuleWithInstanceVariables end def branch_name_or_ref diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb index 539feb3cf1c..24475909b62 100644 --- a/app/controllers/concerns/enforces_two_factor_authentication.rb +++ b/app/controllers/concerns/enforces_two_factor_authentication.rb @@ -73,7 +73,7 @@ module EnforcesTwoFactorAuthentication end def skip_two_factor? - session[:skip_two_factor] && session[:skip_two_factor] > Time.current + session[:skip_two_factor] && session[:skip_two_factor].future? end def two_factor_verifier diff --git a/app/controllers/concerns/google_analytics_csp.rb b/app/controllers/concerns/google_analytics_csp.rb deleted file mode 100644 index 1a8e405928d..00000000000 --- a/app/controllers/concerns/google_analytics_csp.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module GoogleAnalyticsCSP - extend ActiveSupport::Concern - - included do - content_security_policy do |policy| - next unless helpers.google_tag_manager_enabled? || policy.directives.present? - - default_script_src = policy.directives['script-src'] || policy.directives['default-src'] - script_src_values = Array.wrap(default_script_src) | ['*.googletagmanager.com'] - policy.script_src(*script_src_values) - - default_img_src = policy.directives['img-src'] || policy.directives['default-src'] - img_src_values = Array.wrap(default_img_src) | ['*.google-analytics.com', '*.googletagmanager.com'] - policy.img_src(*img_src_values) - - default_connect_src = policy.directives['connect-src'] || policy.directives['default-src'] - connect_src_values = - Array.wrap(default_connect_src) | ['*.google-analytics.com', '*.analytics.google.com', '*.googletagmanager.com'] - policy.connect_src(*connect_src_values) - end - end -end diff --git a/app/controllers/concerns/google_syndication_csp.rb b/app/controllers/concerns/google_syndication_csp.rb deleted file mode 100644 index c55debe448b..00000000000 --- a/app/controllers/concerns/google_syndication_csp.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module GoogleSyndicationCSP - extend ActiveSupport::Concern - - ALLOWED_SRC = ['*.google.com/pagead/landing', 'pagead2.googlesyndication.com/pagead/landing'].freeze - - included do - content_security_policy do |policy| - next unless helpers.google_tag_manager_enabled? || policy.directives.present? - - connect_src_values = Array.wrap( - policy.directives['connect-src'] || policy.directives['default-src'] - ) - - connect_src_values.concat(ALLOWED_SRC) if helpers.google_tag_manager_enabled? - - policy.connect_src(*connect_src_values.uniq) - end - end -end diff --git a/app/controllers/concerns/import/github_oauth.rb b/app/controllers/concerns/import/github_oauth.rb index dc03a132768..ae5a0401155 100644 --- a/app/controllers/concerns/import/github_oauth.rb +++ b/app/controllers/concerns/import/github_oauth.rb @@ -54,23 +54,15 @@ module Import 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, - scope: 'repo, user, user:email', - state: state - ) - else - client.authorize_url(callback_import_url, state) - end + oauth_client.auth_code.authorize_url( + redirect_uri: callback_import_url, + scope: 'repo, user, user:email', + state: state + ) end def get_token(code) - if Feature.enabled?(:remove_legacy_github_client) - oauth_client.auth_code.get_token(code).token - else - client.get_token(code) - end + oauth_client.auth_code.get_token(code).token end def missing_oauth_config diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index 0c15c4d0d3f..b4f5589a059 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -5,7 +5,6 @@ module MembershipActions extend ActiveSupport::Concern def update - update_params = params.require(root_params_key).permit(:access_level, :expires_at) member = members_and_requesters.find(params[:id]) result = Members::UpdateService .new(current_user, update_params) @@ -148,6 +147,10 @@ module MembershipActions membershipable.requesters end + def update_params + params.require(root_params_key).permit(:access_level, :expires_at) + end + def requested_relations(inherited_permissions = :with_inherited_permissions) case params[inherited_permissions].presence when 'exclude' @@ -156,7 +159,8 @@ module MembershipActions [:inherited] else if Feature.enabled?(:webui_members_inherited_users, current_user) - [:inherited, :direct, :shared_from_groups, (:invited_groups if params[:project_id])].compact + project_relations = [:invited_groups, :shared_into_ancestors] + [:inherited, :direct, :shared_from_groups, *(project_relations if params[:project_id])] else [:inherited, :direct] end diff --git a/app/controllers/concerns/onboarding/redirectable.rb b/app/controllers/concerns/onboarding/redirectable.rb new file mode 100644 index 00000000000..7e669db9199 --- /dev/null +++ b/app/controllers/concerns/onboarding/redirectable.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Onboarding + module Redirectable + extend ActiveSupport::Concern + + private + + def after_sign_up_path + if onboarding_status.single_invite? + flash[:notice] = helpers.invite_accepted_notice(onboarding_status.last_invited_member) + onboarding_status.last_invited_member_source.activity_path + else + # Invites will come here if there is more than 1. + path_for_signed_in_user + end + end + + def path_for_signed_in_user + stored_location_for(:user) || last_member_activity_path + end + + def last_member_activity_path + return dashboard_projects_path unless onboarding_status.last_invited_member_source.present? + + onboarding_status.last_invited_member_source.activity_path + end + end +end + +Onboarding::Redirectable.prepend_mod diff --git a/app/controllers/concerns/onboarding/status.rb b/app/controllers/concerns/onboarding/status.rb index 8a99f5a6c12..ea4dc550149 100644 --- a/app/controllers/concerns/onboarding/status.rb +++ b/app/controllers/concerns/onboarding/status.rb @@ -2,21 +2,12 @@ module Onboarding class Status - def self.tracking_label - { free: 'free_registration' } - end - def initialize(params, session, user) @params = params @session = session @user = user end - # overridden in EE - def continue_full_onboarding? - false - end - def single_invite? # If there are more than one member it will mean we have been invited to multiple projects/groups and # are not able to distinguish which one we should putting the user in after registration diff --git a/app/controllers/concerns/planning_hierarchy.rb b/app/controllers/concerns/planning_hierarchy.rb index 5df838bc183..51999a87e26 100644 --- a/app/controllers/concerns/planning_hierarchy.rb +++ b/app/controllers/concerns/planning_hierarchy.rb @@ -7,7 +7,7 @@ module PlanningHierarchy def planning_hierarchy return access_denied! unless can?(current_user, :read_planning_hierarchy, @project) - render 'shared/planning_hierarchy' + route_not_found end # rubocop:enable Gitlab/ModuleWithInstanceVariables end diff --git a/app/controllers/concerns/product_analytics_tracking.rb b/app/controllers/concerns/product_analytics_tracking.rb index e148f5d063a..d4610267897 100644 --- a/app/controllers/concerns/product_analytics_tracking.rb +++ b/app/controllers/concerns/product_analytics_tracking.rb @@ -14,7 +14,7 @@ module ProductAnalyticsTracking end def track_internal_event(*controller_actions, name:, conditions: nil) - custom_conditions = [:trackable_html_request?, *conditions] + custom_conditions = [:trackable_html_request?, :authenticated?, *conditions] after_action only: controller_actions, if: custom_conditions do Gitlab::InternalEvents.track_event( @@ -70,4 +70,8 @@ module ProductAnalyticsTracking cookies[:visitor_id] = { value: uuid, expires: 24.months } uuid end + + def authenticated? + current_user.present? + end end diff --git a/app/controllers/concerns/renders_projects_list.rb b/app/controllers/concerns/renders_projects_list.rb index 2d37bc3f9a5..56383658696 100644 --- a/app/controllers/concerns/renders_projects_list.rb +++ b/app/controllers/concerns/renders_projects_list.rb @@ -5,7 +5,7 @@ module RendersProjectsList def prepare_projects_for_rendering(projects) preload_max_member_access_for_collection(Project, projects) - current_user.preloaded_member_roles_for_projects(projects) if current_user + preload_member_roles(projects) if current_user # Call the count methods on every project, so the BatchLoader would load them all at # once when the entities are rendered @@ -15,4 +15,10 @@ module RendersProjectsList projects end + + def preload_member_roles(projects) + # overridden in EE + end end + +RendersProjectsList.prepend_mod diff --git a/app/controllers/concerns/request_payload_logger.rb b/app/controllers/concerns/request_payload_logger.rb new file mode 100644 index 00000000000..b13164e5c57 --- /dev/null +++ b/app/controllers/concerns/request_payload_logger.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module RequestPayloadLogger + extend ActiveSupport::Concern + include Gitlab::Logging::CloudflareHelper + + def append_info_to_payload(payload) + super + + payload[:ua] = request.env["HTTP_USER_AGENT"] + payload[:remote_ip] = request.remote_ip + payload[Labkit::Correlation::CorrelationId::LOG_KEY] = Labkit::Correlation::CorrelationId.current_id + + payload[:metadata] = Gitlab::ApplicationContext.current + + if defined?(urgency) + payload[:request_urgency] = urgency&.name + payload[:target_duration_s] = urgency&.duration + end + + logged_user = auth_user + if logged_user.present? + payload[:user_id] = logged_user.try(:id) + payload[:username] = logged_user.try(:username) + end + + payload[:queue_duration_s] = request.env[::Gitlab::Middleware::RailsQueueDuration::GITLAB_RAILS_QUEUE_DURATION_KEY] + payload[:response_bytes] = response.body_parts.sum(&:bytesize) if Feature.enabled?(:log_response_length) + + store_cloudflare_headers!(payload, request) + end +end diff --git a/app/controllers/concerns/snippets/blobs_actions.rb b/app/controllers/concerns/snippets/blobs_actions.rb index 2a0491b4df8..955debfc209 100644 --- a/app/controllers/concerns/snippets/blobs_actions.rb +++ b/app/controllers/concerns/snippets/blobs_actions.rb @@ -4,7 +4,6 @@ module Snippets::BlobsActions extend ActiveSupport::Concern include Gitlab::Utils::StrongMemoize - include ExtractsRef include Snippets::SendBlob included do @@ -19,20 +18,14 @@ module Snippets::BlobsActions private - def repository_container - snippet - end - - # rubocop:disable Gitlab/ModuleWithInstanceVariables def blob - assign_ref_vars - - return unless @commit + ref_extractor = ExtractsRef::RefExtractor.new(snippet, params.permit(:id, :ref, :path, :ref_type)) + ref_extractor.extract! + return unless ref_extractor.commit - @repo.blob_at(@commit.id, @path) + snippet.repository.blob_at(ref_extractor.commit.id, ref_extractor.path) end strong_memoize_attr :blob - # rubocop:enable Gitlab/ModuleWithInstanceVariables def ensure_blob render_404 unless blob diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb index 5ceabaa734a..db1cf31d349 100644 --- a/app/controllers/confirmations_controller.rb +++ b/app/controllers/confirmations_controller.rb @@ -4,8 +4,6 @@ class ConfirmationsController < Devise::ConfirmationsController include AcceptsPendingInvitations include GitlabRecaptcha include OneTrustCSP - include GoogleAnalyticsCSP - include GoogleSyndicationCSP prepend_before_action :check_recaptcha, only: :create before_action :load_recaptcha, only: :new diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 29bc48f93e9..1941920325f 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -255,6 +255,12 @@ class GraphqlController < ApplicationController end def authorize_access_api! + if current_user.nil? && + request_authenticator.authentication_token_present? && + Feature.enabled?(:invalid_graphql_auth_401) + render_error('Invalid token', status: :unauthorized) + end + return if can?(current_user, :access_api) render_error('API not accessible for user', status: :forbidden) @@ -301,6 +307,8 @@ class GraphqlController < ApplicationController end def introspection_query_can_use_cache? + return false if Gitlab.dev_or_test_env? + CACHED_INTROSPECTION_QUERY_STRING == graphql_query_object.query_string.squish end diff --git a/app/controllers/groups/autocomplete_sources_controller.rb b/app/controllers/groups/autocomplete_sources_controller.rb index 414461d9e93..86bf65f4723 100644 --- a/app/controllers/groups/autocomplete_sources_controller.rb +++ b/app/controllers/groups/autocomplete_sources_controller.rb @@ -8,6 +8,11 @@ class Groups::AutocompleteSourcesController < Groups::ApplicationController urgency :low, [:issues, :labels, :milestones, :commands, :merge_requests, :members] def members + if Feature.enabled?(:cache_autocomplete_sources_members, current_user) + # Cache the response on the frontend + expires_in 3.minutes + end + render json: ::Groups::ParticipantsService.new(@group, current_user).execute(target) end diff --git a/app/controllers/groups/custom_emoji_controller.rb b/app/controllers/groups/custom_emoji_controller.rb new file mode 100644 index 00000000000..f202c9febba --- /dev/null +++ b/app/controllers/groups/custom_emoji_controller.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Groups + class CustomEmojiController < Groups::ApplicationController + feature_category :code_review_workflow + urgency :low + + before_action do + render_404 unless Feature.enabled?(:custom_emoji) + end + end +end diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index cbed75019f2..5f6b55ea928 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -9,10 +9,6 @@ class Groups::MilestonesController < Groups::ApplicationController feature_category :team_planning urgency :low - before_action do - push_frontend_feature_flag(:content_editor_on_issues, group) - end - def index respond_to do |format| format.html do diff --git a/app/controllers/groups/observability_controller.rb b/app/controllers/groups/observability_controller.rb deleted file mode 100644 index 525407f5849..00000000000 --- a/app/controllers/groups/observability_controller.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true -module Groups - class ObservabilityController < Groups::ApplicationController - include ::Observability::ContentSecurityPolicy - - feature_category :tracing - - before_action :check_observability_allowed - - def dashboards - render_observability - end - - def manage - render_observability - end - - def explore - render_observability - end - - def datasources - render_observability - end - - private - - def render_observability - render 'observability', layout: 'group', locals: { base_layout: 'layouts/fullscreen' } - end - - def check_observability_allowed - render_404 unless Gitlab::Observability.allowed_for_action?(current_user, group, params[:action]) - end - end -end diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb index 1381999ab4c..2b2db2f950c 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true -# rubocop:disable Rails/ApplicationController -class HealthController < ActionController::Base +class HealthController < BaseActionController protect_from_forgery with: :exception, prepend: true include RequiresAllowlistedMonitoringClient @@ -40,4 +39,3 @@ class HealthController < ActionController::Base render json: result.json, status: result.http_status end end -# rubocop:enable Rails/ApplicationController diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb index d7d7ad84bc8..a8ec738caf4 100644 --- a/app/controllers/import/bulk_imports_controller.rb +++ b/app/controllers/import/bulk_imports_controller.rb @@ -152,7 +152,7 @@ class Import::BulkImportsController < ApplicationController allow_local_network: allow_local_requests?, schemes: %w[http https] ) - rescue Gitlab::UrlBlocker::BlockedUrlError => e + rescue Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError => e clear_session_data redirect_to new_group_path(anchor: 'import-group-pane'), alert: _('Specified URL cannot be used: "%{reason}"') % { reason: e.message } diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb index 9ee8e59053f..34fdf513313 100644 --- a/app/controllers/import/fogbugz_controller.rb +++ b/app/controllers/import/fogbugz_controller.rb @@ -128,7 +128,7 @@ class Import::FogbugzController < Import::BaseController allow_local_network: allow_local_requests?, schemes: %w[http https] ) - rescue Gitlab::UrlBlocker::BlockedUrlError => e + rescue Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError => e redirect_to new_import_fogbugz_url, alert: _('Specified URL cannot be used: "%{reason}"') % { reason: e.message } end diff --git a/app/controllers/import/gitea_controller.rb b/app/controllers/import/gitea_controller.rb index 2778b97419a..4e95c6527c3 100644 --- a/app/controllers/import/gitea_controller.rb +++ b/app/controllers/import/gitea_controller.rb @@ -85,7 +85,6 @@ class Import::GiteaController < Import::GithubController @client ||= Gitlab::LegacyGithubImport::Client.new(session[access_token_key], **client_options) end - override :client_options def client_options verified_url, provider_hostname = verify_blocked_uri @@ -99,7 +98,7 @@ class Import::GiteaController < Import::GithubController allow_local_network: allow_local_requests?, schemes: %w[http https] ) - rescue Gitlab::UrlBlocker::BlockedUrlError => e + rescue Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError => e session[access_token_key] = nil redirect_to new_import_url, alert: _('Specified URL cannot be used: "%{reason}"') % { reason: e.message } diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index 28732d58484..2b72ceceb5a 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -192,7 +192,7 @@ class Import::GithubController < Import::BaseController def client_proxy @client_proxy ||= Gitlab::GithubImport::Clients::Proxy.new( - session[access_token_key], client_options + session[access_token_key] ) end @@ -265,10 +265,6 @@ class Import::GithubController < Import::BaseController end # rubocop: enable CodeReuse/ActiveRecord - def client_options - { wait_for_rate_limit_reset: false } - end - def rate_limit_threshold_exceeded head :too_many_requests end diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb index d299613f498..84ccfbc603a 100644 --- a/app/controllers/jwt_controller.rb +++ b/app/controllers/jwt_controller.rb @@ -87,13 +87,22 @@ class JwtController < ApplicationController # We have to parse scope here, because Docker Client does not send an array of scopes, # but rather a flat list and we loose second scope when being processed by Rails: - # scope=scopeA&scope=scopeB + # scope=scopeA&scope=scopeB. + # + # Additionally, according to RFC6749 (https://datatracker.ietf.org/doc/html/rfc6749#section-3.3), some clients may use + # a scope parameter expressed as a list of space-delimited elements. Therefore, we must account for this and split the + # scope parameter value(s) appropriately. # # This method makes to always return an array of scopes def scopes_param return unless params[:scope].present? - Array(Rack::Utils.parse_query(request.query_string)['scope']) + scopes = Array(Rack::Utils.parse_query(request.query_string)['scope']) + if Feature.enabled?(:jwt_auth_space_delimited_scopes, Feature.current_request) + scopes.flat_map(&:split) + else + scopes + end end def auth_user diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb index 9f41c092fa0..61851fd1c60 100644 --- a/app/controllers/metrics_controller.rb +++ b/app/controllers/metrics_controller.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true -# rubocop:disable Rails/ApplicationController -class MetricsController < ActionController::Base +class MetricsController < BaseActionController include RequiresAllowlistedMonitoringClient protect_from_forgery with: :exception, prepend: true @@ -36,4 +35,3 @@ class MetricsController < ActionController::Base ) end end -# rubocop:enable Rails/ApplicationController diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb index 012fa318eea..7889e89fc5c 100644 --- a/app/controllers/oauth/tokens_controller.rb +++ b/app/controllers/oauth/tokens_controller.rb @@ -2,4 +2,7 @@ class Oauth::TokensController < Doorkeeper::TokensController include EnforcesTwoFactorAuthentication + include RequestPayloadLogger + + alias_method :auth_user, :current_user end diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 72b3516ae3f..a97516fddff 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -7,6 +7,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController include InitializesCurrentUserMode include KnownSignIn include AcceptsPendingInvitations + include Onboarding::Redirectable after_action :verify_known_sign_in @@ -169,38 +170,38 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController def sign_in_user_flow(auth_user_class) auth_user = build_auth_user(auth_user_class) new_user = auth_user.new? - user = auth_user.find_and_update! + @user = auth_user.find_and_update! if auth_user.valid_sign_in? # In this case the `#current_user` would not be set. So we can't fetch it # from that in `#context_user`. Pushing it manually here makes the information # available in the logs for this request. - Gitlab::ApplicationContext.push(user: user) - track_event(user, oauth['provider'], 'succeeded') - Gitlab::Tracking.event(self.class.name, "#{oauth['provider']}_sso", user: user) if new_user + Gitlab::ApplicationContext.push(user: @user) + track_event(@user, oauth['provider'], 'succeeded') + Gitlab::Tracking.event(self.class.name, "#{oauth['provider']}_sso", user: @user) if new_user - set_remember_me(user) + set_remember_me(@user) - if user.two_factor_enabled? && !auth_user.bypass_two_factor? - prompt_for_two_factor(user) + if @user.two_factor_enabled? && !auth_user.bypass_two_factor? + prompt_for_two_factor(@user) store_idp_two_factor_status(false) else - if user.deactivated? - user.activate + if @user.deactivated? + @user.activate flash[:notice] = _('Welcome back! Your account had been deactivated due to inactivity but is now reactivated.') end # session variable for storing bypass two-factor request from IDP store_idp_two_factor_status(true) - accept_pending_invitations(user: user) if new_user - persist_accepted_terms_if_required(user) if new_user + accept_pending_invitations(user: @user) if new_user + persist_accepted_terms_if_required(@user) if new_user - perform_registration_tasks(user, oauth['provider']) if new_user - sign_in_and_redirect_or_verify_identity(user, auth_user, new_user) + perform_registration_tasks(@user, oauth['provider']) if new_user + sign_in_and_redirect_or_verify_identity(@user, auth_user, new_user) end else - fail_login(user) + fail_login(@user) end rescue Gitlab::Auth::OAuth::User::SigninDisabledForProviderError handle_disabled_provider @@ -323,9 +324,10 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController store_location_for(:user, after_sign_up_path) end - def after_sign_up_path - users_sign_up_welcome_path + def onboarding_status + Onboarding::Status.new(params.to_unsafe_h.deep_symbolize_keys, session, @user) end + strong_memoize_attr :onboarding_status # overridden in EE def sign_in_and_redirect_or_verify_identity(user, _, _) diff --git a/app/controllers/organizations/application_controller.rb b/app/controllers/organizations/application_controller.rb index d3c3e878bdf..8a99b6804ae 100644 --- a/app/controllers/organizations/application_controller.rb +++ b/app/controllers/organizations/application_controller.rb @@ -27,5 +27,9 @@ module Organizations def authorize_read_organization! access_denied! unless can?(current_user, :read_organization, organization) end + + def authorize_admin_organization! + access_denied! unless can?(current_user, :admin_organization, organization) + end end end diff --git a/app/controllers/organizations/settings_controller.rb b/app/controllers/organizations/settings_controller.rb new file mode 100644 index 00000000000..a81cbf57a42 --- /dev/null +++ b/app/controllers/organizations/settings_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Organizations + class SettingsController < ApplicationController + feature_category :cell + + before_action :authorize_admin_organization! + + def general; end + end +end diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb index 57e5ca4d55a..abb6e46394e 100644 --- a/app/controllers/profiles/notifications_controller.rb +++ b/app/controllers/profiles/notifications_controller.rb @@ -45,11 +45,17 @@ class Profiles::NotificationsController < Profiles::ApplicationController projects = project_notifications.map(&:source) ActiveRecord::Associations::Preloader.new( records: projects, - associations: { namespace: [:route, :owner], group: [], creator: [], project_setting: [] } + associations: project_associations ).call Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects, current_user).execute project_notifications.select { |notification| current_user.can?(:read_project, notification.source) } end # rubocop: enable CodeReuse/ActiveRecord + + def project_associations + { namespace: [:route, :owner], group: [], creator: [], project_setting: [] } + end end + +Profiles::NotificationsController.prepend_mod diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index 0e4d9f3c154..4b6e2f768fa 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -61,7 +61,6 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController def set_index_vars @scopes = Gitlab::Auth.available_scopes_for(current_user) - @scopes.delete(Gitlab::Auth::K8S_PROXY_SCOPE) unless Feature.enabled?(:k8s_proxy_pat, current_user) @active_access_tokens = active_access_tokens end diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index e83b72b71a8..f1646027e8e 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -140,7 +140,10 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController def build_qr_code uri = current_user.otp_provisioning_uri(account_string, issuer: issuer_host) - RQRCode.render_qrcode(uri, :svg, level: :m, unit: 3) + RQRCode::QRCode.new(uri, level: :m).as_svg( + shape_rendering: "crispEdges", + module_size: 3 + ) end def account_string diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index 62233c8c3c9..30c6f4d865a 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -85,7 +85,7 @@ class Projects::ApplicationController < ApplicationController end def require_pages_enabled! - not_found unless @project.pages_available? + not_found unless ::Gitlab::Pages.enabled? end def check_issues_available! diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb index 480e3408023..60c8fe97e81 100644 --- a/app/controllers/projects/autocomplete_sources_controller.rb +++ b/app/controllers/projects/autocomplete_sources_controller.rb @@ -13,6 +13,11 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController urgency :low, [:issues, :labels, :milestones, :commands, :contacts] def members + if Feature.enabled?(:cache_autocomplete_sources_members, current_user) + # Cache the response on the frontend + expires_in 3.minutes + end + render json: ::Projects::ParticipantsService.new(@project, current_user).execute(target) end diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb index f621adbebc7..b37962b850f 100644 --- a/app/controllers/projects/blame_controller.rb +++ b/app/controllers/projects/blame_controller.rb @@ -15,6 +15,7 @@ class Projects::BlameController < Projects::ApplicationController urgency :low, [:show] def show + @ref_type = ref_type load_environment load_blame end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 56e4b22ded2..015e56db012 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -101,8 +101,9 @@ class Projects::BlobController < Projects::ApplicationController ) rescue Files::UpdateService::FileChangedError @conflict = true - @different_project = different_project? - render :edit + render "edit", locals: { + commit_to_fork: @different_project + } end def preview @@ -164,7 +165,7 @@ class Projects::BlobController < Projects::ApplicationController @ref_type = ref_type - if @ref_type == ExtractsRef::BRANCH_REF_TYPE && ambiguous_ref?(@project, @ref) + if @ref_type == ExtractsRef::RefExtractor::BRANCH_REF_TYPE && ambiguous_ref?(@project, @ref) branch = @project.repository.find_branch(@ref) redirect_to project_blob_path(@project, File.join(branch.target, @path)) end diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb index 9d7569047f6..6e4d456ecc1 100644 --- a/app/controllers/projects/cycle_analytics_controller.rb +++ b/app/controllers/projects/cycle_analytics_controller.rb @@ -23,6 +23,7 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController before_action do push_licensed_feature(:cycle_analytics_for_groups) if project.licensed_feature_available?(:cycle_analytics_for_groups) push_licensed_feature(:group_level_analytics_dashboard) if project.licensed_feature_available?(:group_level_analytics_dashboard) + push_frontend_feature_flag(:vsa_predefined_date_ranges, project) if project.licensed_feature_available?(:cycle_analytics_for_projects) push_licensed_feature(:cycle_analytics_for_projects) diff --git a/app/controllers/projects/find_file_controller.rb b/app/controllers/projects/find_file_controller.rb index b5099d555ae..1777497ee52 100644 --- a/app/controllers/projects/find_file_controller.rb +++ b/app/controllers/projects/find_file_controller.rb @@ -14,7 +14,9 @@ class Projects::FindFileController < Projects::ApplicationController urgency :low, [:show, :list] def show - return render_404 unless @repository.commit(@ref) + return render_404 unless @commit + + @ref_type = ref_type respond_to do |format| format.html @@ -22,7 +24,7 @@ class Projects::FindFileController < Projects::ApplicationController end def list - file_paths = @repo.ls_files(@ref) + file_paths = @repo.ls_files(@commit.id) respond_to do |format| format.json { render json: file_paths } diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb index 69d349b1f1d..bacf3192ee6 100644 --- a/app/controllers/projects/incidents_controller.rb +++ b/app/controllers/projects/incidents_controller.rb @@ -11,8 +11,8 @@ class Projects::IncidentsController < Projects::ApplicationController 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(:moved_mr_sidebar, project) - push_frontend_feature_flag(:move_close_into_dropdown, project) push_force_frontend_feature_flag(:linked_work_items, @project&.linked_work_items_feature_flag_enabled?) + push_frontend_feature_flag(:notifications_todos_buttons, project) end feature_category :incident_management diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 9abcc108ace..4849cccac52 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -7,7 +7,6 @@ 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 @@ -46,13 +45,12 @@ class Projects::IssuesController < Projects::ApplicationController before_action do push_frontend_feature_flag(:preserve_unchanged_markdown, project) - push_frontend_feature_flag(:content_editor_on_issues, project&.group) - push_force_frontend_feature_flag(:content_editor_on_issues, project&.content_editor_on_issues_feature_flag_enabled?) push_frontend_feature_flag(:service_desk_new_note_email_native_attachments, project) push_frontend_feature_flag(:saved_replies, current_user) push_frontend_feature_flag(:issues_grid_view) push_frontend_feature_flag(:service_desk_ticket) push_frontend_feature_flag(:issues_list_drawer, project) + push_frontend_feature_flag(:linked_work_items, project) end before_action only: [:index, :show] do @@ -71,8 +69,8 @@ class Projects::IssuesController < Projects::ApplicationController 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(:moved_mr_sidebar, project) - push_frontend_feature_flag(:move_close_into_dropdown, project) push_force_frontend_feature_flag(:linked_work_items, project.linked_work_items_feature_flag_enabled?) + push_frontend_feature_flag(:notifications_todos_buttons, project) end around_action :allow_gitaly_ref_name_caching, only: [:discussions] @@ -277,6 +275,12 @@ class Projects::IssuesController < Projects::ApplicationController @issues = @issuables end + def discussions + Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/425834') + + super + end + protected def index_html_request? @@ -450,7 +454,7 @@ class Projects::IssuesController < Projects::ApplicationController def redirect_if_work_item return unless use_work_items_path?(issue) - redirect_to project_work_items_path(project, issue.iid, params: request.query_parameters) + redirect_to project_work_item_path(project, issue.iid, params: request.query_parameters) end def require_incident_for_incident_routes diff --git a/app/controllers/projects/mattermosts_controller.rb b/app/controllers/projects/mattermosts_controller.rb index a4091ebdf4b..9a3e7e31d68 100644 --- a/app/controllers/projects/mattermosts_controller.rb +++ b/app/controllers/projects/mattermosts_controller.rb @@ -19,10 +19,10 @@ class Projects::MattermostsController < Projects::ApplicationController result, message = integration.configure(current_user, configure_params) if result - flash[:notice] = 'This service is now configured' + flash[:notice] = 'This integration is now configured' redirect_to edit_project_settings_integration_path(@project, integration) else - flash[:alert] = message || 'Failed to configure service' + flash[:alert] = message || 'Failed to configure integration' redirect_to new_project_mattermost_path(@project) end end @@ -31,7 +31,7 @@ class Projects::MattermostsController < Projects::ApplicationController def configure_params params.require(:mattermost).permit(:trigger, :team_id).merge( - url: service_trigger_url(integration), + url: integration_trigger_url(integration), icon_url: asset_url('slash-command-logo.png', skip_pipeline: true)) end diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index 81ff6c215f9..1af0ce3c35e 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -7,11 +7,6 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont feature_category :code_review_workflow - before_action do - push_frontend_feature_flag(:content_editor_on_issues, project&.group) - push_force_frontend_feature_flag(:content_editor_on_issues, project&.content_editor_on_issues_feature_flag_enabled?) - end - private # Normally the methods with `check_(\w+)_available!` pattern are diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index 6a3523b82d9..33a93ed99fb 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -4,7 +4,6 @@ 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! diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 53fd7256b19..ad7b7221e44 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -11,7 +11,6 @@ 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] @@ -37,8 +36,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end before_action only: [:show, :diffs] do - push_frontend_feature_flag(:content_editor_on_issues, project&.group) - push_force_frontend_feature_flag(:content_editor_on_issues, project&.content_editor_on_issues_feature_flag_enabled?) push_frontend_feature_flag(:core_security_mr_widget_counts, project) push_frontend_feature_flag(:issue_assignees_widget, @project) push_frontend_feature_flag(:moved_mr_sidebar, project) @@ -46,9 +43,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:mr_experience_survey, project) push_frontend_feature_flag(:saved_replies, current_user) push_force_frontend_feature_flag(:summarize_my_code_review, summarize_my_code_review_enabled?) - push_frontend_feature_flag(:mr_activity_filters, current_user) push_frontend_feature_flag(:ci_job_failures_in_mr, project) push_frontend_feature_flag(:mr_pipelines_graphql, project) + push_frontend_feature_flag(:notifications_todos_buttons, project) end before_action only: [:edit] do @@ -159,7 +156,11 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo pipelines: PipelineSerializer .new(project: @project, current_user: @current_user) .with_pagination(request, response) - .represent(@pipelines, preload: true), + .represent( + @pipelines, + preload: true, + disable_failed_builds: ::Feature.enabled?(:ci_fix_performance_pipelines_json_endpoint, @project) + ), count: { all: @pipelines.count } diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index 1f4e5b54500..35b65dbce7e 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -24,10 +24,6 @@ class Projects::MilestonesController < Projects::ApplicationController feature_category :team_planning urgency :low - before_action do - push_frontend_feature_flag(:content_editor_on_issues, @project) - end - def index @sort = params[:sort] || 'due_date_asc' @milestones = milestones.sort_by_attribute(@sort) diff --git a/app/controllers/projects/ml/models_controller.rb b/app/controllers/projects/ml/models_controller.rb index 77855b73cbd..4ff7d014723 100644 --- a/app/controllers/projects/ml/models_controller.rb +++ b/app/controllers/projects/ml/models_controller.rb @@ -4,17 +4,30 @@ module Projects module Ml class ModelsController < ::Projects::ApplicationController before_action :check_feature_enabled + before_action :set_model, only: [:show] feature_category :mlops + MAX_MODELS_PER_PAGE = 20 + def index - @models = ::Projects::Ml::ModelFinder.new(@project).execute + @paginator = ::Projects::Ml::ModelFinder.new(@project) + .execute + .keyset_paginate(cursor: params[:cursor], per_page: MAX_MODELS_PER_PAGE) end + def show; end + private def check_feature_enabled render_404 unless can?(current_user, :read_model_registry, @project) end + + def set_model + @model = ::Ml::Model.by_project_id_and_id(@project, params[:model_id]) + + render_404 unless @model + end end end end diff --git a/app/controllers/projects/prometheus/metrics_controller.rb b/app/controllers/projects/prometheus/metrics_controller.rb deleted file mode 100644 index 396841e667d..00000000000 --- a/app/controllers/projects/prometheus/metrics_controller.rb +++ /dev/null @@ -1,137 +0,0 @@ -# frozen_string_literal: true - -module Projects - module Prometheus - class MetricsController < Projects::ApplicationController - before_action :check_feature_availability! - before_action :authorize_admin_project! - before_action :require_prometheus_metrics! - - feature_category :metrics - urgency :low - - def active_common - respond_to do |format| - format.json do - matched_metrics = prometheus_adapter.query(:matched_metrics) || {} - - if matched_metrics.any? - render json: matched_metrics - else - head :no_content - end - end - end - end - - def validate_query - respond_to do |format| - format.json do - result = prometheus_adapter.query(:validate, params[:query]) - - if result - render json: result - else - head :accepted - end - end - end - end - - def new - @metric = project.prometheus_metrics.new - end - - def index - respond_to do |format| - format.json do - metrics = ::PrometheusMetricsFinder.new( - project: project, - ordered: true - ).execute.to_a - - response = {} - if metrics.any? - response[:metrics] = ::PrometheusMetricSerializer - .new(project: project) - .represent(metrics) - end - - render json: response - end - end - end - - def create - @metric = project.prometheus_metrics.create( - metrics_params.to_h.symbolize_keys - ) - - if @metric.persisted? - redirect_to edit_project_settings_integration_path(project, ::Integrations::Prometheus), - notice: _('Metric was successfully added.') - else - render 'new' - end - end - - def update - @metric = prometheus_metric - - if @metric.update(metrics_params) - redirect_to edit_project_settings_integration_path(project, ::Integrations::Prometheus), - notice: _('Metric was successfully updated.') - else - render 'edit' - end - end - - def edit - @metric = prometheus_metric - end - - def destroy - destroy_metrics_service(prometheus_metric).execute - - respond_to do |format| - format.html do - redirect_to edit_project_settings_integration_path(project, ::Integrations::Prometheus), status: :see_other - end - format.json do - head :ok - end - end - end - - private - - def prometheus_adapter - @prometheus_adapter ||= ::Gitlab::Prometheus::Adapter.new(project, project.deployment_platform&.cluster).prometheus_adapter - end - - def require_prometheus_metrics! - render_404 unless prometheus_adapter&.can_query? - end - - def prometheus_metric - @prometheus_metric ||= ::PrometheusMetricsFinder.new(id: params[:id]).execute.first - end - - def update_metrics_service(metric) - ::Projects::Prometheus::Metrics::UpdateService.new(metric, metrics_params) - end - - def destroy_metrics_service(metric) - ::Projects::Prometheus::Metrics::DestroyService.new(metric) - end - - def metrics_params - params.require(:prometheus_metric).permit(:title, :query, :y_label, :unit, :legend, :group) - end - - def check_feature_availability! - render_404 if Feature.enabled?(:remove_monitor_metrics) - end - end - end -end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 6a246219f7d..fa26601204a 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -41,6 +41,7 @@ class ProjectsController < Projects::ApplicationController push_frontend_feature_flag(:remove_monitor_metrics, @project) push_frontend_feature_flag(:explain_code_chat, current_user) push_frontend_feature_flag(:service_desk_custom_email, @project) + push_frontend_feature_flag(:issue_email_participants, @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?) diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb deleted file mode 100644 index f7a601ec0bd..00000000000 --- a/app/controllers/registrations/welcome_controller.rb +++ /dev/null @@ -1,99 +0,0 @@ -# frozen_string_literal: true - -module Registrations - class WelcomeController < ApplicationController - include OneTrustCSP - include GoogleAnalyticsCSP - include GoogleSyndicationCSP - include ::Gitlab::Utils::StrongMemoize - - layout 'minimal' - # TODO: Once this is an ee + SaaS only feature, we can remove this. - # To be completed in https://gitlab.com/gitlab-org/gitlab/-/issues/411858 - skip_before_action :check_two_factor_requirement - - helper_method :welcome_update_params - helper_method :onboarding_status - - feature_category :user_management - - 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.success? - track_event('successfully_submitted_form') - successful_update_hooks - - redirect_to update_success_path - else - render :show - end - end - - private - - def authenticate_user! - return if current_user - - redirect_to new_user_registration_path - end - - def completed_welcome_step? - !current_user.setup_for_company.nil? - end - - def update_params - params.require(:user).permit(:role, :setup_for_company) - end - - def path_for_signed_in_user(user) - stored_location_for(user) || last_member_activity_path - end - - def last_member_activity_path - return dashboard_projects_path unless onboarding_status.last_invited_member_source.present? - - onboarding_status.last_invited_member_source.activity_path - end - - def update_success_path - if onboarding_status.continue_full_onboarding? # trials/regular registration on .com - signup_onboarding_path - elsif onboarding_status.single_invite? # invites w/o tasks due to order - flash[:notice] = helpers.invite_accepted_notice(onboarding_status.last_invited_member) - onboarding_status.last_invited_member_source.activity_path - else - # Subscription registrations goes through here as well. - # Invites will come here too if there is more than 1. - path_for_signed_in_user(current_user) - end - end - - # overridden in EE - def successful_update_hooks; end - - # overridden in EE - def signup_onboarding_path; end - - # overridden in EE - def track_event(action); end - - # overridden in EE - def welcome_update_params - {} - end - - def onboarding_status - Onboarding::Status.new(params.to_unsafe_h.deep_symbolize_keys, session, current_user) - end - strong_memoize_attr :onboarding_status - end -end - -Registrations::WelcomeController.prepend_mod_with('Registrations::WelcomeController') diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index a8b5ca81f49..72636a89433 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -7,13 +7,12 @@ class RegistrationsController < Devise::RegistrationsController include InvisibleCaptchaOnSignup include OneTrustCSP include BizibleCSP - include GoogleAnalyticsCSP - include GoogleSyndicationCSP include PreferredLanguageSwitcher include Gitlab::Tracking::Helpers::WeakPasswordErrorEvent include SkipsAlreadySignedInMessage include Gitlab::RackLoadBalancingHelpers include ::Gitlab::Utils::StrongMemoize + include Onboarding::Redirectable layout 'devise' @@ -26,11 +25,7 @@ class RegistrationsController < Devise::RegistrationsController check_rate_limit!(:user_sign_up, scope: request.ip) end - before_action only: [:new] do - push_frontend_feature_flag(:gitlab_gtm_datalayer, type: :ops) - end - - feature_category :user_management + feature_category :instance_resiliency helper_method :arkose_labs_enabled? helper_method :registration_path_params @@ -60,7 +55,7 @@ class RegistrationsController < Devise::RegistrationsController # Devise sets a flash message on both successful & failed signups, # but we only want to show a message if the resource is blocked by a pending approval. - flash[:notice] = nil unless resource.blocked_pending_approval? + flash[:notice] = nil unless allow_flash_content?(resource) rescue Gitlab::Access::AccessDeniedError redirect_to(new_user_session_path) end @@ -121,6 +116,9 @@ class RegistrationsController < Devise::RegistrationsController def after_sign_up_path_for(user) Gitlab::AppLogger.info(user_created_message(confirmed: user.confirmed?)) + # Member#accept_invite! operates on the member record to change the association, so the user needs reloaded + # to update the collection. + user.reset after_sign_up_path end @@ -146,8 +144,13 @@ class RegistrationsController < Devise::RegistrationsController private - def after_sign_up_path - users_sign_up_welcome_path + def onboarding_status + Onboarding::Status.new(params.to_unsafe_h.deep_symbolize_keys, session, resource) + end + strong_memoize_attr :onboarding_status + + def allow_flash_content?(user) + user.blocked_pending_approval? || onboarding_status.single_invite? end # overridden in EE diff --git a/app/controllers/repositories/lfs_api_controller.rb b/app/controllers/repositories/lfs_api_controller.rb index da243a0301e..d9ca216b168 100644 --- a/app/controllers/repositories/lfs_api_controller.rb +++ b/app/controllers/repositories/lfs_api_controller.rb @@ -26,11 +26,7 @@ module Repositories end if download_request? - if Feature.enabled?(:lfs_batch_direct_downloads, project) - render json: { objects: download_objects! }, content_type: LfsRequest::CONTENT_TYPE - else - render json: { objects: legacy_download_objects! }, content_type: LfsRequest::CONTENT_TYPE - end + render json: { objects: download_objects! }, content_type: LfsRequest::CONTENT_TYPE elsif upload_request? render json: { objects: upload_objects! }, content_type: LfsRequest::CONTENT_TYPE else diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index d247490402f..7fff31c767f 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -35,6 +35,18 @@ class SearchController < ApplicationController update_scope_for_code_search end + before_action only: :show do + push_frontend_feature_flag(:search_notes_hide_archived_projects, current_user) + end + + before_action only: :show do + push_frontend_feature_flag(:search_issues_hide_archived_projects, current_user) + end + + before_action only: :show do + push_frontend_feature_flag(:search_merge_requests_hide_archived_projects, current_user) + end + rescue_from ActiveRecord::QueryCanceled, with: :render_timeout layout 'search' diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index afbadc7f4ac..595d79abcf2 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -12,8 +12,6 @@ class SessionsController < Devise::SessionsController include OneTrustCSP include BizibleCSP include VerifiesWithEmail - include GoogleAnalyticsCSP - include GoogleSyndicationCSP include PreferredLanguageSwitcher include SkipsAlreadySignedInMessage include AcceptsPendingInvitations diff --git a/app/controllers/users/namespace_visits_controller.rb b/app/controllers/users/namespace_visits_controller.rb index 7c96d78e26e..d4f536654ca 100644 --- a/app/controllers/users/namespace_visits_controller.rb +++ b/app/controllers/users/namespace_visits_controller.rb @@ -5,7 +5,6 @@ module Users feature_category :navigation def create - return head :not_found unless Feature.enabled?(:server_side_frecent_namespaces, current_user) return head :bad_request unless params[:type].present? && params[:id].present? Users::TrackNamespaceVisitsWorker.perform_async(params[:type], params[:id], current_user.id, DateTime.now) # rubocop:disable CodeReuse/Worker diff --git a/app/controllers/users/terms_controller.rb b/app/controllers/users/terms_controller.rb index f36b140f3a2..f7eb2aad9dc 100644 --- a/app/controllers/users/terms_controller.rb +++ b/app/controllers/users/terms_controller.rb @@ -4,7 +4,6 @@ module Users class TermsController < ApplicationController include InternalRedirect include OneTrustCSP - include GoogleAnalyticsCSP skip_before_action :authenticate_user!, only: [:index] skip_before_action :enforce_terms! @@ -14,10 +13,6 @@ module Users before_action :terms - before_action only: [:index] do - push_frontend_feature_flag(:gitlab_gtm_datalayer, type: :ops) - end - layout 'terms' feature_category :user_management diff --git a/app/events/merge_requests/draft_state_change_event.rb b/app/events/merge_requests/draft_state_change_event.rb new file mode 100644 index 00000000000..ab5f35a9597 --- /dev/null +++ b/app/events/merge_requests/draft_state_change_event.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module MergeRequests + class DraftStateChangeEvent < Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'required' => %w[ + current_user_id + merge_request_id + ], + 'properties' => { + 'current_user_id' => { 'type' => 'integer' }, + 'merge_request_id' => { 'type' => 'integer' } + } + } + end + end +end diff --git a/app/events/merge_requests/unblocked_state_event.rb b/app/events/merge_requests/unblocked_state_event.rb new file mode 100644 index 00000000000..2cf79059cf7 --- /dev/null +++ b/app/events/merge_requests/unblocked_state_event.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module MergeRequests + class UnblockedStateEvent < Gitlab::EventStore::Event + def schema + { + 'type' => 'object', + 'required' => %w[ + current_user_id + merge_request_id + ], + 'properties' => { + 'current_user_id' => { 'type' => 'integer' }, + 'merge_request_id' => { 'type' => 'integer' } + } + } + end + end +end diff --git a/app/experiments/build_ios_app_guide_email_experiment.rb b/app/experiments/build_ios_app_guide_email_experiment.rb deleted file mode 100644 index d334a6a30d9..00000000000 --- a/app/experiments/build_ios_app_guide_email_experiment.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -class BuildIosAppGuideEmailExperiment < ApplicationExperiment - control { false } - candidate { true } -end diff --git a/app/finders/concerns/packages/finder_helper.rb b/app/finders/concerns/packages/finder_helper.rb index 0ae99782cd3..585b35981a6 100644 --- a/app/finders/concerns/packages/finder_helper.rb +++ b/app/finders/concerns/packages/finder_helper.rb @@ -13,11 +13,13 @@ module Packages project.packages.installable end - def packages_visible_to_user(user, within_group:) + def packages_visible_to_user(user, within_group:, with_package_registry_enabled: false) return ::Packages::Package.none unless within_group return ::Packages::Package.none unless Ability.allowed?(user, :read_group, within_group) projects = projects_visible_to_reporters(user, within_group: within_group) + projects = projects.with_package_registry_enabled if with_package_registry_enabled + ::Packages::Package.for_projects(projects.select(:id)).installable end diff --git a/app/finders/merge_requests/oldest_per_commit_finder.rb b/app/finders/merge_requests/oldest_per_commit_finder.rb index 5da7a08e36c..16b5964e242 100644 --- a/app/finders/merge_requests/oldest_per_commit_finder.rb +++ b/app/finders/merge_requests/oldest_per_commit_finder.rb @@ -18,8 +18,8 @@ module MergeRequests mapping = {} shas = commits.map(&:id) - # To include merge requests by the merge/squash SHA, we don't need to go - # through any diff rows. + # To include merge requests by the merged/merge/squash SHA, we don't need + # to go through any diff rows. # # We can't squeeze all this into a single query, as the diff based data # relies on a GROUP BY. On the other hand, retrieving MRs by their merge @@ -27,17 +27,19 @@ module MergeRequests @project .merge_requests .preload_target_project - .by_merge_or_squash_commit_sha(shas) + .by_merged_or_merge_or_squash_commit_sha(shas) .each do |mr| - # Merge/squash SHAs can't be in the merge request itself. It _is_ - # possible a newer merge request includes the commit, but in that case - # we still want the oldest merge request. + # SHAs for merge commits, squash commits, and rebased source SHAs, + # can't be in the merge request source branch. It _is_ possible a + # newer merge request includes the commit, but in that case we still + # want the oldest merge request. # # It's also possible that a merge request produces both a squashed # commit and a merge commit. In that case we want to store the mapping # for both the SHAs. mapping[mr.squash_commit_sha] = mr if mr.squash_commit_sha mapping[mr.merge_commit_sha] = mr if mr.merge_commit_sha + mapping[mr.merged_commit_sha] = mr if mr.merged_commit_sha end remaining = shas - mapping.keys diff --git a/app/finders/packages/maven/package_finder.rb b/app/finders/packages/maven/package_finder.rb index cc28d951f52..03855afb6e4 100644 --- a/app/finders/packages/maven/package_finder.rb +++ b/app/finders/packages/maven/package_finder.rb @@ -4,11 +4,7 @@ module Packages module Maven class PackageFinder < ::Packages::GroupOrProjectPackageFinder def execute - packages.last - end - - def execute! - packages.last! + packages end private diff --git a/app/finders/packages/npm/packages_for_user_finder.rb b/app/finders/packages/npm/packages_for_user_finder.rb index f42e49f9184..dc1d3b6e7fe 100644 --- a/app/finders/packages/npm/packages_for_user_finder.rb +++ b/app/finders/packages/npm/packages_for_user_finder.rb @@ -3,6 +3,8 @@ module Packages module Npm class PackagesForUserFinder < ::Packages::GroupOrProjectPackageFinder + extend ::Gitlab::Utils::Override + def execute packages end @@ -13,6 +15,11 @@ module Packages base.npm .with_name(@params[:package_name]) end + + override :group_packages + def group_packages + packages_visible_to_user(@current_user, within_group: @project_or_group, with_package_registry_enabled: true) + end end end end diff --git a/app/finders/projects/ml/model_finder.rb b/app/finders/projects/ml/model_finder.rb index 99c66f53de7..1e407ba4aa4 100644 --- a/app/finders/projects/ml/model_finder.rb +++ b/app/finders/projects/ml/model_finder.rb @@ -11,7 +11,7 @@ module Projects ::Ml::Model .by_project(@project) .including_latest_version - .limit(100) # This is a temporary limit before we add pagination + .with_version_count end end end diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index e6ee4355fd4..87edf36d1ce 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -61,7 +61,7 @@ class ProjectsFinder < UnionFinder collection = Project.wrap_with_cte(collection) if use_cte collection = filter_projects(collection) - sort(collection) + sort(collection).allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/427628") end private diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb index cb824aca33f..e09de1f6612 100644 --- a/app/finders/snippets_finder.rb +++ b/app/finders/snippets_finder.rb @@ -42,6 +42,7 @@ class SnippetsFinder < UnionFinder include FinderMethods include Gitlab::Utils::StrongMemoize include CreatedAtFilter + include Gitlab::Allowable attr_reader :current_user, :params @@ -79,6 +80,7 @@ class SnippetsFinder < UnionFinder snippets = all_snippets snippets = by_ids(snippets) snippets = snippets.with_optional_visibility(visibility_from_scope) + snippets = hide_created_by_banned_user(snippets) end by_created_at(snippets) @@ -87,7 +89,7 @@ class SnippetsFinder < UnionFinder def return_all_available_and_permited? # Currently limited to access_levels `admin` and `auditor` # See policies/base_policy.rb files for specifics. - params[:all_available] && current_user&.can_read_all_resources? + params[:all_available] && can?(current_user, :read_all_resources) end def all_snippets @@ -126,7 +128,7 @@ class SnippetsFinder < UnionFinder queries = [] queries << personal_snippets unless only_project? - if Ability.allowed?(current_user, :read_cross_project) + if can?(current_user, :read_cross_project) queries << snippets_of_visible_projects queries << snippets_of_authorized_projects if current_user end @@ -207,6 +209,14 @@ class SnippetsFinder < UnionFinder snippets.id_in(params[:ids]) end + def hide_created_by_banned_user(snippets) + # if admin -> return all snippets, if not-admin -> filter out snippets by banned user + return snippets if can?(current_user, :read_all_resources) + return snippets unless Feature.enabled?(:hide_snippets_of_banned_users) + + snippets.without_created_by_banned_user + end + def author strong_memoize(:author) do next unless params[:author].present? diff --git a/app/finders/vs_code/settings/settings_finder.rb b/app/finders/vs_code/settings/settings_finder.rb new file mode 100644 index 00000000000..459ccdbe566 --- /dev/null +++ b/app/finders/vs_code/settings/settings_finder.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module VsCode + module Settings + class SettingsFinder + def initialize(current_user, setting_types) + @current_user = current_user + @setting_types = setting_types + end + + def execute + relation = User.find(current_user.id).vscode_settings + return relation unless setting_types.present? + + relation.by_setting_type(setting_types) + end + + private + + attr_accessor :current_user, :setting_types + end + end +end diff --git a/app/graphql/mutations/achievements/update_user_achievement_priorities.rb b/app/graphql/mutations/achievements/update_user_achievement_priorities.rb new file mode 100644 index 00000000000..077b4810fdc --- /dev/null +++ b/app/graphql/mutations/achievements/update_user_achievement_priorities.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Mutations + module Achievements + class UpdateUserAchievementPriorities < BaseMutation + graphql_name 'UserAchievementPrioritiesUpdate' + + field :user_achievements, + [::Types::Achievements::UserAchievementType], + null: false, + description: 'Updated user achievements.' + + argument :user_achievement_ids, + [::Types::GlobalIDType[::Achievements::UserAchievement]], + required: true, + description: 'Global IDs of the user achievements being prioritized, ' \ + 'ordered from highest to lowest priority.' + + def resolve(args) + user_achievements = args.delete(:user_achievement_ids).map { |id| find_object(id) } + + user_achievements.each do |user_achievement| + unless Ability.allowed?(current_user, :update_owned_user_achievement, user_achievement) + raise_resource_not_available_error! + end + end + + result = ::Achievements::UpdateUserAchievementPrioritiesService.new(current_user, user_achievements).execute + { user_achievements: result.payload, errors: result.errors } + end + + def find_object(id) + ::Gitlab::Graphql::Lazy.force(GitlabSchema.object_from_id(id, expected_type: ::Achievements::UserAchievement)) + end + end + end +end diff --git a/app/graphql/mutations/ci/job/retry.rb b/app/graphql/mutations/ci/job/retry.rb index bfb9b902cc5..5ccc33de33e 100644 --- a/app/graphql/mutations/ci/job/retry.rb +++ b/app/graphql/mutations/ci/job/retry.rb @@ -6,6 +6,12 @@ module Mutations class Retry < Base graphql_name 'JobRetry' + JobID = ::Types::GlobalIDType[::Ci::Processable] + + argument :id, JobID, + required: true, + description: 'ID of the job to mutate.' + field :job, Types::Ci::JobType, null: true, diff --git a/app/graphql/mutations/merge_requests/accept.rb b/app/graphql/mutations/merge_requests/accept.rb index 64572091379..220ebea22c7 100644 --- a/app/graphql/mutations/merge_requests/accept.rb +++ b/app/graphql/mutations/merge_requests/accept.rb @@ -7,7 +7,7 @@ module Mutations authorize :accept_merge_request description <<~DESC Accepts a merge request. - When accepted, the source branch will be merged into the target branch, either + When accepted, the source branch will be scheduled to merge into the target branch, either immediately if possible, or using one of the automatic merge strategies. DESC @@ -59,7 +59,7 @@ module Mutations service = AutoMergeService.new(project, current_user, merge_params) service.execute(merge_request, merge_params[:auto_merge_strategy]) else - merge_service.execute(merge_request) + merge_request.merge_async(current_user.id, merge_params) end { diff --git a/app/graphql/mutations/packages/protection/rule/create.rb b/app/graphql/mutations/packages/protection/rule/create.rb new file mode 100644 index 00000000000..36eaec334d6 --- /dev/null +++ b/app/graphql/mutations/packages/protection/rule/create.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Mutations + module Packages + module Protection + module Rule + class Create < ::Mutations::BaseMutation + graphql_name 'CreatePackagesProtectionRule' + description 'Creates a protection rule to restrict access to project packages. ' \ + 'Available only when feature flag `packages_protected_packages` is enabled.' + + include FindsProject + + authorize :admin_package + + argument :project_path, + GraphQL::Types::ID, + required: true, + description: 'Full path of the project where a protection rule is located.' + + argument :package_name_pattern, + GraphQL::Types::String, + required: true, + description: + 'Package name protected by the protection rule. For example `@my-scope/my-package-*`. ' \ + 'Wildcard character `*` allowed.' + + argument :package_type, + Types::Packages::Protection::RulePackageTypeEnum, + required: true, + description: 'Package type protected by the protection rule. For example `NPM`.' + + argument :push_protected_up_to_access_level, + Types::Packages::Protection::RuleAccessLevelEnum, + required: true, + description: + 'Max GitLab access level unable to push a package. For example `DEVELOPER`, `MAINTAINER`, `OWNER`.' + + field :package_protection_rule, + Types::Packages::Protection::RuleType, + null: true, + description: 'Packages protection rule after mutation.' + + def resolve(project_path:, **kwargs) + project = authorized_find!(project_path) + + if Feature.disabled?(:packages_protected_packages, project) + raise_resource_not_available_error!("'packages_protected_packages' feature flag is disabled") + end + + response = ::Packages::Protection::CreateRuleService.new(project: project, current_user: current_user, + params: kwargs).execute + + { package_protection_rule: response.payload[:package_protection_rule], errors: response.errors } + end + end + end + end + end +end diff --git a/app/graphql/mutations/users/set_namespace_commit_email.rb b/app/graphql/mutations/users/set_namespace_commit_email.rb index 72ef0635bb3..db1c33595f2 100644 --- a/app/graphql/mutations/users/set_namespace_commit_email.rb +++ b/app/graphql/mutations/users/set_namespace_commit_email.rb @@ -20,7 +20,7 @@ module Mutations null: true, description: 'User namespace commit email after mutation.' - authorize :read_namespace + authorize :read_namespace_via_membership def resolve(args) namespace = authorized_find!(args[:namespace_id]) diff --git a/app/graphql/mutations/work_items/linked_items/add.rb b/app/graphql/mutations/work_items/linked_items/add.rb index e0c17a61205..4029d17d4ac 100644 --- a/app/graphql/mutations/work_items/linked_items/add.rb +++ b/app/graphql/mutations/work_items/linked_items/add.rb @@ -16,8 +16,6 @@ module Mutations private def update_links(work_item, params) - Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/419555') - gids = params.delete(:work_items_ids) work_items = begin GitlabSchema.parse_gids(gids, expected_type: ::WorkItem).map(&:find) diff --git a/app/graphql/mutations/work_items/linked_items/base.rb b/app/graphql/mutations/work_items/linked_items/base.rb index a1d9bced930..8a6201ffdf7 100644 --- a/app/graphql/mutations/work_items/linked_items/base.rb +++ b/app/graphql/mutations/work_items/linked_items/base.rb @@ -5,8 +5,7 @@ module Mutations module LinkedItems class Base < BaseMutation # Limit maximum number of items that can be linked at a time to avoid overloading the DB - # See https://gitlab.com/gitlab-org/gitlab/-/issues/419555 - MAX_WORK_ITEMS = 3 + MAX_WORK_ITEMS = 10 argument :id, ::Types::GlobalIDType[::WorkItem], required: true, description: 'Global ID of the work item.' @@ -33,7 +32,7 @@ module Mutations def resolve(**args) work_item = authorized_find!(id: args.delete(:id)) - raise_resource_not_available_error! unless work_item.project.linked_work_items_feature_flag_enabled? + raise_resource_not_available_error! unless work_item.resource_parent.linked_work_items_feature_flag_enabled? service_response = update_links(work_item, args) diff --git a/app/graphql/mutations/work_items/update.rb b/app/graphql/mutations/work_items/update.rb index f22e9bcf393..228a9e52355 100644 --- a/app/graphql/mutations/work_items/update.rb +++ b/app/graphql/mutations/work_items/update.rb @@ -10,7 +10,7 @@ module Mutations include Mutations::WorkItems::UpdateArguments include Mutations::WorkItems::Widgetable - authorize :update_work_item + authorize :read_work_item field :work_item, Types::WorkItemType, null: true, @@ -22,11 +22,13 @@ module Mutations work_item = authorized_find!(id: id) widget_params = extract_widget_params!(work_item.work_item_type, attributes) - interpret_quick_actions!(work_item, current_user, widget_params, attributes) + # Only checks permissions for base attributes because widgets define their own permissions independently + raise_resource_not_available_error! unless attributes.empty? || can_update?(work_item) + update_result = ::WorkItems::UpdateService.new( - container: work_item.project, + container: work_item.resource_parent, current_user: current_user, params: attributes, widget_params: widget_params, @@ -62,6 +64,10 @@ module Mutations widget_params.merge!(parsed_params[:widgets]) attributes.merge!(parsed_params[:common]) end + + def can_update?(work_item) + current_user.can?(:update_work_item, work_item) + end end end end diff --git a/app/graphql/resolvers/achievements/user_achievements_for_user_resolver.rb b/app/graphql/resolvers/achievements/user_achievements_for_user_resolver.rb new file mode 100644 index 00000000000..673babcf14a --- /dev/null +++ b/app/graphql/resolvers/achievements/user_achievements_for_user_resolver.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Resolvers + module Achievements + # rubocop:disable Graphql/ResolverType -- the type is inherited from the parent class + class UserAchievementsForUserResolver < UserAchievementsResolver + def resolve_with_lookahead + super.order_by_priority_asc + end + end + # rubocop:enable Graphql/ResolverType + end +end diff --git a/app/graphql/resolvers/admin/analytics/usage_trends/measurements_resolver.rb b/app/graphql/resolvers/admin/analytics/usage_trends/measurements_resolver.rb index b5a19d38b9c..0c9607d9413 100644 --- a/app/graphql/resolvers/admin/analytics/usage_trends/measurements_resolver.rb +++ b/app/graphql/resolvers/admin/analytics/usage_trends/measurements_resolver.rb @@ -7,7 +7,7 @@ module Resolvers class MeasurementsResolver < BaseResolver include Gitlab::Graphql::Authorize::AuthorizeResource - type Types::Admin::Analytics::UsageTrends::MeasurementType, null: true + type Types::Admin::Analytics::UsageTrends::MeasurementType.connection_type, null: true argument :identifier, Types::Admin::Analytics::UsageTrends::MeasurementIdentifierEnum, required: true, diff --git a/app/graphql/resolvers/analytics/cycle_analytics/base_issue_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/base_issue_resolver.rb index 8128023aecb..768265752d5 100644 --- a/app/graphql/resolvers/analytics/cycle_analytics/base_issue_resolver.rb +++ b/app/graphql/resolvers/analytics/cycle_analytics/base_issue_resolver.rb @@ -48,3 +48,5 @@ module Resolvers end end end + +Resolvers::Analytics::CycleAnalytics::BaseIssueResolver.prepend_mod diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb index 6f847221f1b..17db91a685f 100644 --- a/app/graphql/resolvers/base_resolver.rb +++ b/app/graphql/resolvers/base_resolver.rb @@ -11,26 +11,25 @@ module Resolvers @requires_argument = true end + def self.requires_argument? + !!@requires_argument + end + def self.calls_gitaly! @calls_gitaly = true end + def self.calls_gitaly? + !!@calls_gitaly + 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). + # thus disabling the default `connection` option. def self.calculate_ext_conn_complexity false end - def self.field_options - extra_options = { - requires_argument: @requires_argument, - calls_gitaly: @calls_gitaly - }.compact - - super.merge(extra_options) - end - def self.singular_type return unless type @@ -63,8 +62,13 @@ module Resolvers type parent.singular_type, null: true def ready?(**args) - ready, early_return = super - [ready, select_result(early_return)] + value = super + + if value.is_a?(Array) + [value[0], select_result(value[1])] + else + value + end end def resolve(**args) diff --git a/app/graphql/resolvers/blobs_resolver.rb b/app/graphql/resolvers/blobs_resolver.rb index 546eeb76ff5..27a15381b43 100644 --- a/app/graphql/resolvers/blobs_resolver.rb +++ b/app/graphql/resolvers/blobs_resolver.rb @@ -36,7 +36,7 @@ module Resolvers ref ||= repository.root_ref validate_ref(ref) - ref = ExtractsRef.qualify_ref(ref, ref_type) + ref = ExtractsRef::RefExtractor.qualify_ref(ref, ref_type) repository.blobs_at(paths.map { |path| [ref, path] }).tap do |blobs| blobs.each do |blob| diff --git a/app/graphql/resolvers/ci/config_resolver.rb b/app/graphql/resolvers/ci/config_resolver.rb index ec6ede58cf5..8c85a6aebea 100644 --- a/app/graphql/resolvers/ci/config_resolver.rb +++ b/app/graphql/resolvers/ci/config_resolver.rb @@ -30,11 +30,20 @@ module Resolvers required: false, description: 'Run pipeline creation simulation, or only do static check.' - def resolve(project_path:, content:, sha: nil, dry_run: false) + argument :skip_verify_project_sha, GraphQL::Types::Boolean, + required: false, + alpha: { milestone: '16.5' }, + description: "If the provided `sha` is found in the project's repository but is not " \ + "associated with a Git reference (a detached commit), the verification fails and a " \ + "validation error is returned. Otherwise, verification passes, even if the `sha` is " \ + "invalid. Set to `true` to skip this verification process." + + def resolve(project_path:, content:, sha: nil, dry_run: false, skip_verify_project_sha: false) project = authorized_find!(project_path: project_path) result = ::Gitlab::Ci::Lint - .new(project: project, current_user: context[:current_user], sha: sha) + .new(project: project, current_user: context[:current_user], sha: sha, + verify_project_sha: !skip_verify_project_sha) .validate(content, dry_run: dry_run) response(result) diff --git a/app/graphql/resolvers/clusters/agent_tokens_resolver.rb b/app/graphql/resolvers/clusters/agent_tokens_resolver.rb index 0b9422db2a9..313d71aa345 100644 --- a/app/graphql/resolvers/clusters/agent_tokens_resolver.rb +++ b/app/graphql/resolvers/clusters/agent_tokens_resolver.rb @@ -3,7 +3,7 @@ module Resolvers module Clusters class AgentTokensResolver < BaseResolver - type Types::Clusters::AgentTokenType, null: true + type Types::Clusters::AgentTokenType.connection_type, null: true alias_method :agent, :object diff --git a/app/graphql/resolvers/concerns/caching_array_resolver.rb b/app/graphql/resolvers/concerns/caching_array_resolver.rb index 62649518142..15bf9a90e46 100644 --- a/app/graphql/resolvers/concerns/caching_array_resolver.rb +++ b/app/graphql/resolvers/concerns/caching_array_resolver.rb @@ -22,7 +22,7 @@ # # **important**: If the cardinality of your collection is likely to be greater than 100, # then you will want to pass `max_page_size:` as part of the field definition -# or (ideally) as part of the resolver `field_options`. +# or (ideally) set `max_page_size` in the resolver. # # How to implement: # -------------------- diff --git a/app/graphql/resolvers/concerns/work_items/look_ahead_preloads.rb b/app/graphql/resolvers/concerns/work_items/look_ahead_preloads.rb index 92fb9ec5cef..71833fbd2b9 100644 --- a/app/graphql/resolvers/concerns/work_items/look_ahead_preloads.rb +++ b/app/graphql/resolvers/concerns/work_items/look_ahead_preloads.rb @@ -14,7 +14,8 @@ module WorkItems { work_item_type: :work_item_type, web_url: { namespace: :route, project: [:project_namespace, { namespace: :route }] }, - widgets: { work_item_type: :enabled_widget_definitions } + widgets: { work_item_type: :enabled_widget_definitions }, + archived: :project } end @@ -48,7 +49,8 @@ module WorkItems { project: [:project_feature, :group] }, - :author + :author, + *super ] end end diff --git a/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb index 793b73342ab..187cb15ccc5 100644 --- a/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb +++ b/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb @@ -4,7 +4,6 @@ module Resolvers module ErrorTracking class SentryErrorsResolver < BaseResolver type Types::ErrorTracking::SentryErrorType.connection_type, null: true - extension Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension argument :search_term, ::GraphQL::Types::String, description: 'Search query for the Sentry error details.', @@ -31,10 +30,6 @@ module Resolvers Gitlab::Graphql::ExternallyPaginatedArray.new(previous_cursor, next_cursor, *issues) end - - def self.field_options - super.merge(connection: false) # we manage the pagination manually, so opt out of the connection field extension - end end end end diff --git a/app/graphql/resolvers/group_issues_resolver.rb b/app/graphql/resolvers/group_issues_resolver.rb index 7bbc662c6c8..5e0fb27bafa 100644 --- a/app/graphql/resolvers/group_issues_resolver.rb +++ b/app/graphql/resolvers/group_issues_resolver.rb @@ -11,7 +11,11 @@ module Resolvers before_connection_authorization do |nodes, _| projects = nodes.map(&:project) - ActiveRecord::Associations::Preloader.new(records: projects, associations: :namespace).call + ActiveRecord::Associations::Preloader.new(records: projects, associations: project_associations).call + end + + def self.project_associations + [:namespace] end def ready?(**args) @@ -24,3 +28,5 @@ module Resolvers end end # rubocop:enable Graphql/ResolverType + +Resolvers::GroupIssuesResolver.prepend_mod diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index 589366ba26d..34f14eee0e5 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -23,7 +23,11 @@ module Resolvers projects = nodes.map(&:project) ::Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects, current_user).execute ::Preloaders::GroupPolicyPreloader.new(projects.filter_map(&:group), current_user).execute - ActiveRecord::Associations::Preloader.new(records: projects, associations: :namespace).call + ActiveRecord::Associations::Preloader.new(records: projects, associations: project_associations).call + end + + def self.project_associations + [:namespace] end def ready?(**args) @@ -62,3 +66,5 @@ module Resolvers end end end + +Resolvers::IssuesResolver.prepend_mod diff --git a/app/graphql/resolvers/kas/agent_configurations_resolver.rb b/app/graphql/resolvers/kas/agent_configurations_resolver.rb index 74c5cbe55f1..6e35f64c2ee 100644 --- a/app/graphql/resolvers/kas/agent_configurations_resolver.rb +++ b/app/graphql/resolvers/kas/agent_configurations_resolver.rb @@ -3,7 +3,7 @@ module Resolvers module Kas class AgentConfigurationsResolver < BaseResolver - type Types::Kas::AgentConfigurationType, null: true + type Types::Kas::AgentConfigurationType.connection_type, null: true # Calls Gitaly via KAS calls_gitaly! diff --git a/app/graphql/resolvers/last_commit_resolver.rb b/app/graphql/resolvers/last_commit_resolver.rb index acf7826ab13..ff5701ede8c 100644 --- a/app/graphql/resolvers/last_commit_resolver.rb +++ b/app/graphql/resolvers/last_commit_resolver.rb @@ -12,7 +12,7 @@ module Resolvers # Ensure merge commits can be returned by sending nil to Gitaly instead of '/' path = tree.path == '/' ? nil : tree.path commit = Gitlab::Git::Commit.last_for_path(tree.repository, - ExtractsRef.qualify_ref(tree.sha, tree.ref_type), path, literal_pathspec: true) + ExtractsRef::RefExtractor.qualify_ref(tree.sha, tree.ref_type), path, literal_pathspec: true) ::Commit.new(commit, tree.repository.project) if commit end diff --git a/app/graphql/resolvers/merge_request_pipelines_resolver.rb b/app/graphql/resolvers/merge_request_pipelines_resolver.rb index deb698c63e1..45159e0edd5 100644 --- a/app/graphql/resolvers/merge_request_pipelines_resolver.rb +++ b/app/graphql/resolvers/merge_request_pipelines_resolver.rb @@ -11,9 +11,7 @@ module Resolvers # Return at most 500 pipelines for each MR. # Merge requests generally have many fewer pipelines than this. - def self.field_options - super.merge(max_page_size: 500) - end + max_page_size 500 def resolve(**args) return unless project diff --git a/app/graphql/resolvers/noteable/notes_resolver.rb b/app/graphql/resolvers/noteable/notes_resolver.rb index 0d25c747ffb..b4bd1068723 100644 --- a/app/graphql/resolvers/noteable/notes_resolver.rb +++ b/app/graphql/resolvers/noteable/notes_resolver.rb @@ -7,6 +7,11 @@ module Resolvers type Types::Notes::NoteType.connection_type, null: false + argument :filter, Types::WorkItems::NotesFilterTypeEnum, + required: false, + default_value: ::UserPreference::NOTES_FILTERS[:all_notes], + description: 'Type of notes collection: ALL_NOTES, ONLY_COMMENTS, ONLY_ACTIVITY.' + before_connection_authorization do |nodes, current_user| next if nodes.blank? @@ -16,8 +21,9 @@ module Resolvers ::Preloaders::Projects::NotesPreloader.new(project, current_user).call(nodes) end - def resolve_with_lookahead(*) - apply_lookahead(object.notes.fresh) + def resolve_with_lookahead(**args) + notes = NotesFinder.new(current_user, build_params(args)).execute + apply_lookahead(notes) end private @@ -31,6 +37,17 @@ module Resolvers award_emoji: [:award_emoji] } end + + def build_params(args) + params = { + project: object.project, + target: object + } + + params[:notes_filter] = args[:filter] if args[:filter].present? + + params + end end end end diff --git a/app/graphql/resolvers/package_pipelines_resolver.rb b/app/graphql/resolvers/package_pipelines_resolver.rb index 7f610915489..40e5456164a 100644 --- a/app/graphql/resolvers/package_pipelines_resolver.rb +++ b/app/graphql/resolvers/package_pipelines_resolver.rb @@ -5,7 +5,7 @@ module Resolvers include Gitlab::Graphql::Authorize::AuthorizeResource type Types::Ci::PipelineType.connection_type, null: true - extension Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension + extras [:lookahead] authorizes_object! authorize :read_pipeline @@ -41,14 +41,6 @@ module Resolvers end end - # we manage the pagination manually, so opt out of the connection field extension - def self.field_options - super.merge( - connection: false, - extras: [:lookahead] - ) - end - private def lazy_load_pipeline(id) @@ -59,6 +51,7 @@ module Resolvers def default_value_for(first:, last:, after:, before:) Gitlab::Graphql::Pagination::ActiveRecordArrayConnection.new( [], + context: context, first: first, last: last, after: after, diff --git a/app/graphql/resolvers/paginated_tree_resolver.rb b/app/graphql/resolvers/paginated_tree_resolver.rb index de48fbafb04..48c94c144dd 100644 --- a/app/graphql/resolvers/paginated_tree_resolver.rb +++ b/app/graphql/resolvers/paginated_tree_resolver.rb @@ -3,7 +3,6 @@ module Resolvers class PaginatedTreeResolver < BaseResolver type Types::Tree::TreeType.connection_type, null: true - extension Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension calls_gitaly! @@ -50,9 +49,5 @@ module Resolvers extensions: { code: e.code, gitaly_code: e.status, service: e.service } ) end - - def self.field_options - super.merge(connection: false) # we manage the pagination manually, so opt out of the connection field extension - end end end diff --git a/app/graphql/resolvers/project_packages_protection_rules_resolver.rb b/app/graphql/resolvers/project_packages_protection_rules_resolver.rb new file mode 100644 index 00000000000..5d3d0fbf79d --- /dev/null +++ b/app/graphql/resolvers/project_packages_protection_rules_resolver.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Resolvers + class ProjectPackagesProtectionRulesResolver < BaseResolver + type Types::Packages::Protection::RuleType.connection_type, null: true + + alias_method :project, :object + + def resolve(**_args) + return [] if Feature.disabled?(:packages_protected_packages, project) + + project.package_protection_rules + end + end +end diff --git a/app/graphql/resolvers/projects_resolver.rb b/app/graphql/resolvers/projects_resolver.rb index 08981f2c441..8dd409a8173 100644 --- a/app/graphql/resolvers/projects_resolver.rb +++ b/app/graphql/resolvers/projects_resolver.rb @@ -4,7 +4,7 @@ module Resolvers class ProjectsResolver < BaseResolver include ProjectSearchArguments - type Types::ProjectType, null: true + type Types::ProjectType.connection_type, null: true argument :ids, [GraphQL::Types::ID], required: false, diff --git a/app/graphql/resolvers/user_notes_count_resolver.rb b/app/graphql/resolvers/user_notes_count_resolver.rb index b91815c72f5..ebc54a1c6e8 100644 --- a/app/graphql/resolvers/user_notes_count_resolver.rb +++ b/app/graphql/resolvers/user_notes_count_resolver.rb @@ -20,7 +20,7 @@ module Resolvers def authorized_resource?(object) ability = "read_#{object.class.name.underscore}".to_sym - context[:current_user].present? && Ability.allowed?(context[:current_user], ability, object) + Ability.allowed?(context[:current_user], ability, object) end end end diff --git a/app/graphql/resolvers/work_items/ancestors_resolver.rb b/app/graphql/resolvers/work_items/ancestors_resolver.rb new file mode 100644 index 00000000000..33adbfc9c86 --- /dev/null +++ b/app/graphql/resolvers/work_items/ancestors_resolver.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Resolvers + module WorkItems + class AncestorsResolver < BaseResolver + prepend ::WorkItems::LookAheadPreloads + + type Types::WorkItemType.connection_type, null: true + + def resolve_with_lookahead + ancestors = object.ancestors + return WorkItem.none unless ancestors + + truncate_ancestors(apply_lookahead(ancestors)).reverse! + end + + private + + def truncate_ancestors(ancestors) + # Iterate from closest ancestor until root or first missing ancestor + authorized = authorized_ancestors(ancestors) + + previous_ancestor = object.work_item + authorized.take_while do |ancestor| + is_direct_parent = previous_ancestor.work_item_parent.id == ancestor.id + previous_ancestor = ancestor + + is_direct_parent + end + end + + def authorized_ancestors(ancestors) + preload_resource_parents(ancestors) + + DeclarativePolicy.user_scope do + ancestors.select { |ancestor| Ability.allowed?(current_user, :read_work_item, ancestor) } + end + end + + def preload_resource_parents(work_items) + projects = work_items.filter_map(&:project) + namespaces = work_items.filter_map(&:namespace) + group_namespaces = namespaces.select { |n| n.type == ::Group.sti_name } + + ::Preloaders::GroupPolicyPreloader.new(group_namespaces, current_user).execute if group_namespaces.any? + return unless projects.any? + + ::Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects, current_user).execute + ::Preloaders::GroupPolicyPreloader.new(projects.filter_map(&:namespace), current_user).execute + ActiveRecord::Associations::Preloader.new(records: projects, associations: [:namespace]).call + end + + def unconditional_includes + [:namespace, :work_item_parent, :work_item_type] + end + end + end +end diff --git a/app/graphql/resolvers/work_items/linked_items_resolver.rb b/app/graphql/resolvers/work_items/linked_items_resolver.rb index 35a6974163a..108d5d41b62 100644 --- a/app/graphql/resolvers/work_items/linked_items_resolver.rb +++ b/app/graphql/resolvers/work_items/linked_items_resolver.rb @@ -28,7 +28,7 @@ module Resolvers private def related_work_items(type) - return [] unless work_item.project.linked_work_items_feature_flag_enabled? + return [] unless work_item.resource_parent.linked_work_items_feature_flag_enabled? work_item.linked_work_items(current_user, preload: { project: [:project_feature, :group] }, link_type: type) 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 index b40d85e8003..0bbd51a537e 100644 --- a/app/graphql/resolvers/work_items/work_item_discussions_resolver.rb +++ b/app/graphql/resolvers/work_items/work_item_discussions_resolver.rb @@ -4,7 +4,6 @@ module Resolvers module WorkItems class WorkItemDiscussionsResolver < BaseResolver include Gitlab::Graphql::Authorize::AuthorizeResource - extension Gitlab::Graphql::Extensions::ForwardOnlyExternallyPaginatedArrayExtension authorize :read_work_item authorizes_object! @@ -31,11 +30,6 @@ module Resolvers ) 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 diff --git a/app/graphql/types/achievements/user_achievement_type.rb b/app/graphql/types/achievements/user_achievement_type.rb index 7cdcb66576c..b92b2c42bee 100644 --- a/app/graphql/types/achievements/user_achievement_type.rb +++ b/app/graphql/types/achievements/user_achievement_type.rb @@ -48,6 +48,11 @@ module Types Types::TimeType, null: true, description: 'Timestamp the achievement was revoked.' + + field :priority, + GraphQL::Types::Int, + null: true, + description: 'Priority of the user achievement.' end end end diff --git a/app/graphql/types/base_argument.rb b/app/graphql/types/base_argument.rb index d2bc1d55408..cda7fa4a5df 100644 --- a/app/graphql/types/base_argument.rb +++ b/app/graphql/types/base_argument.rb @@ -7,7 +7,6 @@ module Types attr_reader :doc_reference def initialize(*args, **kwargs, &block) - init_gitlab_deprecation(kwargs) @doc_reference = kwargs.delete(:see) # our custom addition `nullable` which allows us to declare diff --git a/app/graphql/types/base_enum.rb b/app/graphql/types/base_enum.rb index 45e78b330fb..ca86e399f6b 100644 --- a/app/graphql/types/base_enum.rb +++ b/app/graphql/types/base_enum.rb @@ -5,12 +5,6 @@ module Types class BaseEnum < GraphQL::Schema::Enum class CustomValue < GraphQL::Schema::EnumValue include Gitlab::Graphql::Deprecations - - def initialize(name, desc = nil, **kwargs) - init_gitlab_deprecation(kwargs) - - super(name, desc, **kwargs) - end end enum_value_class(CustomValue) diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb index caeb81c95cb..886490ba62f 100644 --- a/app/graphql/types/base_field.rb +++ b/app/graphql/types/base_field.rb @@ -11,13 +11,15 @@ module Types attr_reader :doc_reference def initialize(**kwargs, &block) - init_gitlab_deprecation(kwargs) - @calls_gitaly = !!kwargs.delete(:calls_gitaly) + @requires_argument = kwargs.delete(:requires_argument) + @calls_gitaly = kwargs.delete(:calls_gitaly) @doc_reference = kwargs.delete(:see) - @constant_complexity = kwargs[:complexity].is_a?(Integer) && kwargs[:complexity] > 0 - @requires_argument = !!kwargs.delete(:requires_argument) + + given_complexity = kwargs[:complexity] || kwargs[:resolver_class].try(:complexity) + @constant_complexity = given_complexity.is_a?(Integer) && given_complexity > 0 + kwargs[:complexity] = field_complexity(kwargs[:resolver_class], given_complexity) + @authorize = Array.wrap(kwargs.delete(:authorize)) - kwargs[:complexity] = field_complexity(kwargs[:resolver_class], kwargs[:complexity]) after_connection_extensions = kwargs.delete(:late_extensions) || [] super(**kwargs, &block) @@ -31,11 +33,12 @@ module Types end def may_call_gitaly? - @constant_complexity || @calls_gitaly + @constant_complexity || calls_gitaly? end def requires_argument? - @requires_argument || arguments.values.any? { |argument| argument.type.non_null? } + value = @requires_argument.nil? ? @resolver_class.try(:requires_argument?) : @requires_argument + !!value || arguments.values.any? { |argument| argument.type.non_null? } end # By default fields authorize against the current object, but that is not how our @@ -82,7 +85,7 @@ module Types end def calls_gitaly? - @calls_gitaly + !!(@calls_gitaly.nil? ? @resolver_class.try(:calls_gitaly?) : @calls_gitaly) end def constant_complexity? diff --git a/app/graphql/types/ci/ci_cd_setting_type.rb b/app/graphql/types/ci/ci_cd_setting_type.rb index 8a49c5a6a95..f01c63d717b 100644 --- a/app/graphql/types/ci/ci_cd_setting_type.rb +++ b/app/graphql/types/ci/ci_cd_setting_type.rb @@ -29,12 +29,6 @@ module Types null: true, description: 'Whether merge pipelines are enabled.', method: :merge_pipelines_enabled? - # TODO(Issue 422295): this is EE only and should be moved to the EE file - field :merge_trains_enabled, - GraphQL::Types::Boolean, - null: true, - description: 'Whether merge trains are enabled.', - method: :merge_trains_enabled? field :project, Types::ProjectType, null: true, diff --git a/app/graphql/types/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb index e18770c2708..6882a495259 100644 --- a/app/graphql/types/ci/detailed_status_type.rb +++ b/app/graphql/types/ci/detailed_status_type.rb @@ -16,20 +16,34 @@ module Types field :favicon, GraphQL::Types::String, null: true, description: 'Favicon of the status.' field :group, GraphQL::Types::String, null: true, - description: 'Group of the status.' + description: 'Group of the status.', + deprecated: { + reason: 'The `group` attribute is deprecated. Use `name` instead', + milestone: '16.4' + } field :has_details, GraphQL::Types::Boolean, null: true, description: 'Indicates if the status has further details.', method: :has_details? field :icon, GraphQL::Types::String, null: true, - description: 'Icon of the status.' + description: 'Icon of the status.', + deprecated: { + reason: 'The `icon` attribute is deprecated. Use `name` to ' \ + 'identify the status to display instead', + milestone: '16.4' + } field :id, GraphQL::Types::String, null: false, description: 'ID for a detailed status.', extras: [:parent] field :label, GraphQL::Types::String, null: true, - calls_gitaly: true, - description: 'Label of the status.' + description: 'Human-readable label of the status (e.g. success).' + field :name, GraphQL::Types::String, null: true, + description: 'Machine-readable status name (e.g. SUCCESS).' field :text, GraphQL::Types::String, null: true, - description: 'Text of the status.' + description: 'Text of the status.', + deprecated: { + reason: 'The `text` attribute is being deprecated. Use `label` instead', + milestone: '16.4' + } field :tooltip, GraphQL::Types::String, null: true, description: 'Tooltip associated with the status.', method: :status_tooltip diff --git a/app/graphql/types/ci/job_trace_type.rb b/app/graphql/types/ci/job_trace_type.rb index 405c640115d..62fb9340b53 100644 --- a/app/graphql/types/ci/job_trace_type.rb +++ b/app/graphql/types/ci/job_trace_type.rb @@ -21,7 +21,7 @@ module Types def html_summary(last_lines:) object.html( last_lines: last_lines.clamp(1, 100), - max_size: Feature.enabled?(:graphql_job_trace_html_summary_max_size) ? MAX_SIZE_B : nil + max_size: MAX_SIZE_B ).html_safe end end diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb index ba638d4bc47..dfdc3752916 100644 --- a/app/graphql/types/ci/pipeline_type.rb +++ b/app/graphql/types/ci/pipeline_type.rb @@ -18,6 +18,9 @@ module Types field :iid, GraphQL::Types::String, null: false, description: 'Internal ID of the pipeline.' + field :name, GraphQL::Types::String, null: true, + description: 'Name of the pipeline.' + field :sha, GraphQL::Types::String, null: true, method: :sha, description: "SHA of the pipeline's commit." do @@ -61,7 +64,7 @@ module Types description: "Timestamp of the pipeline's last activity." field :started_at, Types::TimeType, null: true, - description: 'Timestamp when the pipeline was started.' + description: 'Timestamp when the pipeline was started.' field :finished_at, Types::TimeType, null: true, description: "Timestamp of the pipeline's completion." @@ -178,6 +181,24 @@ module Types field :merge_request_event_type, Types::Ci::PipelineMergeRequestEventTypeEnum, null: true, description: "Event type of the pipeline associated with a merge request." + field :total_jobs, GraphQL::Types::Int, null: false, method: :total_size, description: "The total number of jobs in the pipeline" + + field :failure_reason, GraphQL::Types::String, null: true, description: "The reason why the pipeline failed" + + field :triggered_by_path, GraphQL::Types::String, null: true, description: "The path that triggered this pipeline" + + field :source, GraphQL::Types::String, null: true, method: :source, description: "The source of the pipeline" + + field :child, GraphQL::Types::Boolean, null: false, method: :child?, description: "If the pipeline is a child or not" + + field :latest, GraphQL::Types::Boolean, null: false, method: :latest?, calls_gitaly: true, description: "If the pipeline is the latest one or not" + + field :ref_text, GraphQL::Types::String, null: false, method: :ref_text, description: "The reference text from the presenter", calls_gitaly: true + + field :merge_request, Types::MergeRequestType, null: true, description: "The MR which the Pipeline is attached to" + + field :stuck, GraphQL::Types::Boolean, method: :stuck?, null: false, description: "If the pipeline is stuck." + def commit BatchLoader::GraphQL.wrap(object.commit) end diff --git a/app/graphql/types/clusters/agent_type.rb b/app/graphql/types/clusters/agent_type.rb index c0989796141..04a4a719ba1 100644 --- a/app/graphql/types/clusters/agent_type.rb +++ b/app/graphql/types/clusters/agent_type.rb @@ -33,7 +33,7 @@ module Types null: true, authorize: :read_project - field :tokens, Types::Clusters::AgentTokenType.connection_type, + field :tokens, description: 'Tokens associated with the cluster agent.', null: true, resolver: ::Resolvers::Clusters::AgentTokensResolver diff --git a/app/graphql/types/custom_emoji_type.rb b/app/graphql/types/custom_emoji_type.rb index b02cd56e6df..08ac3172f2c 100644 --- a/app/graphql/types/custom_emoji_type.rb +++ b/app/graphql/types/custom_emoji_type.rb @@ -7,7 +7,7 @@ module Types authorize :read_custom_emoji - connection_type_class(Types::CountableConnectionType) + connection_type_class Types::CountableConnectionType expose_permissions Types::PermissionTypes::CustomEmoji diff --git a/app/graphql/types/error_tracking/sentry_error_collection_type.rb b/app/graphql/types/error_tracking/sentry_error_collection_type.rb index 9790560929b..009da29d9c7 100644 --- a/app/graphql/types/error_tracking/sentry_error_collection_type.rb +++ b/app/graphql/types/error_tracking/sentry_error_collection_type.rb @@ -16,7 +16,8 @@ module Types resolver: Resolvers::ErrorTracking::SentryErrorStackTraceResolver field :errors, description: "Collection of Sentry Errors.", - resolver: Resolvers::ErrorTracking::SentryErrorsResolver + resolver: Resolvers::ErrorTracking::SentryErrorsResolver, + connection_extension: Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension field :external_url, GraphQL::Types::String, null: true, diff --git a/app/graphql/types/issues/negated_issue_filter_input_type.rb b/app/graphql/types/issues/negated_issue_filter_input_type.rb index fc39efd2493..12f87509ade 100644 --- a/app/graphql/types/issues/negated_issue_filter_input_type.rb +++ b/app/graphql/types/issues/negated_issue_filter_input_type.rb @@ -11,7 +11,7 @@ module Types argument :assignee_usernames, [GraphQL::Types::String], required: false, description: 'Usernames of users not assigned to the issue.' - argument :author_username, GraphQL::Types::String, + argument :author_username, [GraphQL::Types::String], required: false, description: "Username of a user who didn't author the issue." argument :iids, [GraphQL::Types::String], diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index 4fd2b245de9..e6625e44508 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -102,6 +102,12 @@ module Types calls_gitaly: true, description: 'Detailed merge status of the merge request.' + field :mergeability_checks, [::Types::MergeRequests::MergeabilityCheckType], + null: false, + description: 'Status of all mergeability checks of the merge request.', + method: :all_mergeability_checks_results, + alpha: { milestone: '16.5' } + field :mergeable_discussions_state, GraphQL::Types::Boolean, null: true, calls_gitaly: true, description: 'Indicates if all discussions in the merge request have been resolved, allowing the merge request to be merged.' diff --git a/app/graphql/types/merge_requests/mergeability_check_identifier_enum.rb b/app/graphql/types/merge_requests/mergeability_check_identifier_enum.rb new file mode 100644 index 00000000000..ac25c98941c --- /dev/null +++ b/app/graphql/types/merge_requests/mergeability_check_identifier_enum.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module MergeRequests + class MergeabilityCheckIdentifierEnum < BaseEnum + graphql_name 'MergeabilityCheckIdentifier' + description 'Representation of mergeability check identifier.' + + MergeRequest.all_mergeability_checks.each do |check_class| + identifier = check_class.identifier.to_s + + value identifier.upcase, + value: identifier, + description: "Mergeability check identifier is #{identifier}." + end + end + end +end diff --git a/app/graphql/types/merge_requests/mergeability_check_status_enum.rb b/app/graphql/types/merge_requests/mergeability_check_status_enum.rb new file mode 100644 index 00000000000..d3b95316b67 --- /dev/null +++ b/app/graphql/types/merge_requests/mergeability_check_status_enum.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module MergeRequests + class MergeabilityCheckStatusEnum < BaseEnum + graphql_name 'MergeabilityCheckStatus' + description 'Representation of whether a mergeability check passed, failed or is inactive.' + + value 'SUCCESS', + value: 'success', + description: 'Mergeability check has passed.' + + value 'FAILED', + value: 'failed', + description: 'Mergeability check has failed. The merge request cannot be merged.' + + value 'INACTIVE', + value: 'inactive', + description: 'Mergeability check is disabled via settings.' + end + end +end diff --git a/app/graphql/types/merge_requests/mergeability_check_type.rb b/app/graphql/types/merge_requests/mergeability_check_type.rb new file mode 100644 index 00000000000..4ef44c4b511 --- /dev/null +++ b/app/graphql/types/merge_requests/mergeability_check_type.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Types + module MergeRequests + class MergeabilityCheckType < BaseObject # rubocop:disable Graphql/AuthorizeTypes + graphql_name 'MergeRequestMergeabilityCheck' + description 'Mergeability check of the merge request.' + + field :identifier, + ::Types::MergeRequests::MergeabilityCheckIdentifierEnum, + null: false, + description: 'Identifier of the mergeability check.' + + field :status, + ::Types::MergeRequests::MergeabilityCheckStatusEnum, + null: false, + description: 'Status of the mergeability check.' + + def status + object.status.to_s + end + + def identifier + object.identifier.to_s + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 445f26e2fcf..3af7140aed3 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -12,6 +12,7 @@ module Types mount_mutation Mutations::Achievements::DeleteUserAchievement, alpha: { milestone: '16.1' } mount_mutation Mutations::Achievements::Revoke, alpha: { milestone: '15.10' } mount_mutation Mutations::Achievements::Update, alpha: { milestone: '15.11' } + mount_mutation Mutations::Achievements::UpdateUserAchievementPriorities, alpha: { milestone: '16.5' } mount_mutation Mutations::Admin::SidekiqQueues::DeleteJobs mount_mutation Mutations::AlertManagement::CreateAlertIssue mount_mutation Mutations::AlertManagement::UpdateAlertStatus @@ -169,6 +170,7 @@ module Types mount_mutation Mutations::Packages::BulkDestroy, extensions: [::Gitlab::Graphql::Limit::FieldCallCount => { limit: 1 }] mount_mutation Mutations::Packages::DestroyFile + mount_mutation Mutations::Packages::Protection::Rule::Create, alpha: { milestone: '16.5' } mount_mutation Mutations::Packages::DestroyFiles mount_mutation Mutations::Packages::Cleanup::Policy::Update mount_mutation Mutations::Echo diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb index 3420f16213f..85bda507ff7 100644 --- a/app/graphql/types/namespace_type.rb +++ b/app/graphql/types/namespace_type.rb @@ -4,7 +4,7 @@ module Types class NamespaceType < BaseObject graphql_name 'Namespace' - authorize :read_namespace + authorize :read_namespace_via_membership field :id, GraphQL::Types::ID, null: false, description: 'ID of the namespace.' diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb index e7e032c67c6..ffdaab0a5f6 100644 --- a/app/graphql/types/notes/note_type.rb +++ b/app/graphql/types/notes/note_type.rb @@ -5,6 +5,8 @@ module Types class NoteType < BaseObject graphql_name 'Note' + connection_type_class Types::CountableConnectionType + authorize :read_note expose_permissions Types::PermissionTypes::Note diff --git a/app/graphql/types/packages/helm/dependency_type.rb b/app/graphql/types/packages/helm/dependency_type.rb index 72a47d0af51..6ba14145fb5 100644 --- a/app/graphql/types/packages/helm/dependency_type.rb +++ b/app/graphql/types/packages/helm/dependency_type.rb @@ -12,7 +12,7 @@ module Types field :alias, GraphQL::Types::String, null: true, description: 'Alias of the dependency.', resolver_method: :resolve_alias field :condition, GraphQL::Types::String, null: true, description: 'Condition of the dependency.' field :enabled, GraphQL::Types::Boolean, null: true, description: 'Indicates the dependency is enabled.' - field :import_values, [GraphQL::Types::JSON], null: true, description: 'Import-values of the dependency.', hash_key: "import-values" # rubocop:disable Graphql/JSONType + field :import_values, [GraphQL::Types::JSON], null: true, description: 'Import-values of the dependency.', hash_key: :'import-values' # rubocop:disable Graphql/JSONType field :name, GraphQL::Types::String, null: true, description: 'Name of the dependency.' field :repository, GraphQL::Types::String, null: true, description: 'Repository of the dependency.' field :tags, [GraphQL::Types::String], null: true, description: 'Tags of the dependency.' diff --git a/app/graphql/types/packages/helm/metadata_type.rb b/app/graphql/types/packages/helm/metadata_type.rb index ccc5a3029cd..77062a48bc3 100644 --- a/app/graphql/types/packages/helm/metadata_type.rb +++ b/app/graphql/types/packages/helm/metadata_type.rb @@ -10,8 +10,8 @@ module Types # Need to be synced with app/validators/json_schemas/helm_metadata.json field :annotations, GraphQL::Types::JSON, null: true, description: 'Annotations for the chart.' # rubocop:disable Graphql/JSONType - field :api_version, GraphQL::Types::String, null: false, description: 'API version of the chart.', hash_key: "apiVersion" - field :app_version, GraphQL::Types::String, null: true, description: 'App version of the chart.', hash_key: "appVersion" + field :api_version, GraphQL::Types::String, null: false, description: 'API version of the chart.', hash_key: :apiVersion + field :app_version, GraphQL::Types::String, null: true, description: 'App version of the chart.', hash_key: :appVersion field :condition, GraphQL::Types::String, null: true, description: 'Condition for the chart.' field :dependencies, [Types::Packages::Helm::DependencyType], null: true, description: 'Dependencies of the chart.' field :deprecated, GraphQL::Types::Boolean, null: true, description: 'Indicates if the chart is deprecated.' @@ -19,12 +19,12 @@ module Types field :home, GraphQL::Types::String, null: true, description: 'URL of the home page.' field :icon, GraphQL::Types::String, null: true, description: 'URL to an SVG or PNG image for the chart.' field :keywords, [GraphQL::Types::String], null: true, description: 'Keywords for the chart.' - field :kube_version, GraphQL::Types::String, null: true, description: 'Kubernetes versions for the chart.', hash_key: "kubeVersion" + field :kube_version, GraphQL::Types::String, null: true, description: 'Kubernetes versions for the chart.', hash_key: :kubeVersion field :maintainers, [Types::Packages::Helm::MaintainerType], null: true, description: 'Maintainers of the chart.' field :name, GraphQL::Types::String, null: false, description: 'Name of the chart.' field :sources, [GraphQL::Types::String], null: true, description: 'URLs of the source code for the chart.' field :tags, GraphQL::Types::String, null: true, description: 'Tags for the chart.' - field :type, GraphQL::Types::String, null: true, description: 'Type of the chart.', hash_key: "appVersion" + field :type, GraphQL::Types::String, null: true, description: 'Type of the chart.', hash_key: :appVersion field :version, GraphQL::Types::String, null: false, description: 'Version of the chart.' end end diff --git a/app/graphql/types/packages/package_base_type.rb b/app/graphql/types/packages/package_base_type.rb index cc41169bcda..aa580d48709 100644 --- a/app/graphql/types/packages/package_base_type.rb +++ b/app/graphql/types/packages/package_base_type.rb @@ -23,6 +23,7 @@ module Types field :package_type, Types::Packages::PackageTypeEnum, null: false, description: 'Package type.' field :project, Types::ProjectType, null: false, description: 'Project where the package is stored.' field :status, Types::Packages::PackageStatusEnum, null: false, description: 'Package status.' + field :status_message, GraphQL::Types::String, null: true, description: 'Status message.' field :tags, Types::Packages::PackageTagType.connection_type, null: true, description: 'Package tags.' field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.' field :version, GraphQL::Types::String, null: true, description: 'Version string.' diff --git a/app/graphql/types/packages/package_type.rb b/app/graphql/types/packages/package_type.rb index f6586670c72..4c5b16cc41e 100644 --- a/app/graphql/types/packages/package_type.rb +++ b/app/graphql/types/packages/package_type.rb @@ -10,6 +10,7 @@ module Types field :pipelines, resolver: Resolvers::PackagePipelinesResolver, + connection_extension: Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension, description: <<-DESC Pipelines that built the package. Max page size #{Resolvers::PackagePipelinesResolver::MAX_PAGE_SIZE}. DESC diff --git a/app/graphql/types/packages/protection/rule_access_level_enum.rb b/app/graphql/types/packages/protection/rule_access_level_enum.rb new file mode 100644 index 00000000000..098a3e48100 --- /dev/null +++ b/app/graphql/types/packages/protection/rule_access_level_enum.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Packages + module Protection + class RuleAccessLevelEnum < BaseEnum + graphql_name 'PackagesProtectionRuleAccessLevel' + description 'Access level of a package protection rule resource' + + ::Packages::Protection::Rule.push_protected_up_to_access_levels.each_key do |access_level_key| + value access_level_key.upcase, value: access_level_key.to_s, + description: "#{access_level_key.capitalize} access." + end + end + end + end +end diff --git a/app/graphql/types/packages/protection/rule_package_type_enum.rb b/app/graphql/types/packages/protection/rule_package_type_enum.rb new file mode 100644 index 00000000000..28e9df76adc --- /dev/null +++ b/app/graphql/types/packages/protection/rule_package_type_enum.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Packages + module Protection + class RulePackageTypeEnum < BaseEnum + graphql_name 'PackagesProtectionRulePackageType' + description 'Package type of a package protection rule resource' + + ::Packages::Protection::Rule.package_types.each_key do |package_type| + value package_type.upcase, value: package_type, + description: "Packages of the #{package_type} format" + end + end + end + end +end diff --git a/app/graphql/types/packages/protection/rule_type.rb b/app/graphql/types/packages/protection/rule_type.rb new file mode 100644 index 00000000000..1e969d39ce2 --- /dev/null +++ b/app/graphql/types/packages/protection/rule_type.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Types + module Packages + module Protection + class RuleType < ::Types::BaseObject + graphql_name 'PackagesProtectionRule' + description 'A packages protection rule designed to protect packages ' \ + 'from being pushed by users with a certain access level.' + + authorize :admin_package + + field :package_name_pattern, + GraphQL::Types::String, + null: false, + description: + 'Package name protected by the protection rule. For example `@my-scope/my-package-*`. ' \ + 'Wildcard character `*` allowed.' + + field :package_type, + Types::Packages::Protection::RulePackageTypeEnum, + null: false, + description: 'Package type protected by the protection rule. For example `NPM`.' + + field :push_protected_up_to_access_level, + Types::Packages::Protection::RuleAccessLevelEnum, + null: false, + description: + 'Max GitLab access level unable to push a package. For example `DEVELOPER`, `MAINTAINER`, `OWNER`.' + end + end + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 2738d4da6c2..95caefc3825 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -311,6 +311,12 @@ module Types null: true, description: 'Packages cleanup policy for the project.' + field :packages_protection_rules, + Types::Packages::Protection::RuleType.connection_type, + null: true, + description: 'Packages protection rules for the project.', + resolver: Resolvers::ProjectPackagesProtectionRulesResolver + field :jobs, type: Types::Ci::JobType.connection_type, null: true, @@ -524,7 +530,7 @@ module Types complexity: 5, resolver: ::Resolvers::TimelogResolver - field :agent_configurations, ::Types::Kas::AgentConfigurationType.connection_type, + field :agent_configurations, null: true, description: 'Agent configurations defined by the project', resolver: ::Resolvers::Kas::AgentConfigurationsResolver diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index d02b3e4136f..d185007f05b 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -109,7 +109,7 @@ module Types null: true, resolver: Resolvers::ProjectResolver, description: "Find a project." - field :projects, Types::ProjectType.connection_type, + field :projects, null: true, resolver: Resolvers::ProjectsResolver, description: "Find projects visible to the current user." @@ -154,7 +154,7 @@ module Types null: true, resolver: Resolvers::TopicsResolver, description: "Find project topics." - field :usage_trends_measurements, Types::Admin::Analytics::UsageTrends::MeasurementType.connection_type, + field :usage_trends_measurements, null: true, description: 'Get statistics on the instance.', resolver: Resolvers::Admin::Analytics::UsageTrends::MeasurementsResolver diff --git a/app/graphql/types/repository_type.rb b/app/graphql/types/repository_type.rb index 40eade3a4d1..a012b60b1c6 100644 --- a/app/graphql/types/repository_type.rb +++ b/app/graphql/types/repository_type.rb @@ -20,6 +20,7 @@ module Types field :exists, GraphQL::Types::Boolean, null: false, method: :exists?, calls_gitaly: true, description: 'Indicates a corresponding Git repository exists on disk.' field :paginated_tree, Types::Tree::TreeType.connection_type, null: true, resolver: Resolvers::PaginatedTreeResolver, calls_gitaly: true, + connection_extension: Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension, max_page_size: 100, description: 'Paginated tree of the repository.' field :root_ref, GraphQL::Types::String, null: true, calls_gitaly: true, diff --git a/app/graphql/types/security/codequality_reports_comparer_type.rb b/app/graphql/types/security/codequality_reports_comparer_type.rb index 3b0f790af81..8088bf84627 100644 --- a/app/graphql/types/security/codequality_reports_comparer_type.rb +++ b/app/graphql/types/security/codequality_reports_comparer_type.rb @@ -11,7 +11,7 @@ module Types field :report, type: CodequalityReportsComparer::ReportType, null: true, - hash_key: 'data', + hash_key: :data, description: 'Compared codequality report.' end # rubocop: enable Graphql/AuthorizeTypes diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb index 6e6d0edbe15..16f01979a43 100644 --- a/app/graphql/types/snippet_type.rb +++ b/app/graphql/types/snippet_type.rb @@ -45,6 +45,11 @@ module Types description: 'Visibility Level of the snippet.', null: false + field :hidden, GraphQL::Types::Boolean, + description: 'Indicates the snippet is hidden because the author has been banned.', + null: false, + method: :hidden_due_to_author_ban? + field :created_at, Types::TimeType, description: 'Timestamp this snippet was created.', null: false diff --git a/app/graphql/types/todo_action_enum.rb b/app/graphql/types/todo_action_enum.rb index 45b83ea1d64..63f96332eab 100644 --- a/app/graphql/types/todo_action_enum.rb +++ b/app/graphql/types/todo_action_enum.rb @@ -13,5 +13,6 @@ module Types value 'review_requested', value: 9, description: 'Review was requested from the user.' value 'member_access_requested', value: 10, description: 'Group or project access requested from the user.' value 'review_submitted', value: 11, description: 'Merge request authored by the user received a review.' + value 'okr_checkin_requested', value: 12, description: 'An OKR assigned to the user requires an update.' end end diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb index 9e5f6810aca..47d486265b0 100644 --- a/app/graphql/types/user_interface.rb +++ b/app/graphql/types/user_interface.rb @@ -160,7 +160,7 @@ module Types description: "Achievements for the user. " \ "Only returns for namespaces where the `achievements` feature flag is enabled.", extras: [:lookahead], - resolver: ::Resolvers::Achievements::UserAchievementsResolver + resolver: ::Resolvers::Achievements::UserAchievementsForUserResolver field :bio, type: ::GraphQL::Types::String, diff --git a/app/graphql/types/user_state_enum.rb b/app/graphql/types/user_state_enum.rb index de15fc19682..72503840bf5 100644 --- a/app/graphql/types/user_state_enum.rb +++ b/app/graphql/types/user_state_enum.rb @@ -5,8 +5,11 @@ module Types graphql_name 'UserState' description 'Possible states of a user' - value 'active', 'User is active and is able to use the system.', value: 'active' - value 'blocked', 'User has been blocked and is prevented from using the system.', value: 'blocked' - value 'deactivated', 'User is no longer active and is unable to use the system.', value: 'deactivated' + value 'active', 'User is active and can use the system.', value: 'active' + value 'blocked', 'User has been blocked by an administrator and cannot use the system.', value: 'blocked' + value 'deactivated', 'User is no longer active and cannot use the system.', value: 'deactivated' + value 'banned', 'User is blocked, and their contributions are hidden.', value: 'banned' + value 'ldap_blocked', 'User has been blocked by the system.', value: 'ldap_blocked' + value 'blocked_pending_approval', 'User is blocked and pending approval.', value: 'blocked_pending_approval' end end diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index 170f28103eb..87ca5fddf14 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -4,6 +4,9 @@ module Types class UserType < ::Types::BaseObject graphql_name 'UserCore' description 'Core representation of a GitLab user.' + + connection_type_class Types::CountableConnectionType + implements ::Types::UserInterface authorize :read_user diff --git a/app/graphql/types/work_item_type.rb b/app/graphql/types/work_item_type.rb index 05798ba3d2f..103a1c0ec9b 100644 --- a/app/graphql/types/work_item_type.rb +++ b/app/graphql/types/work_item_type.rb @@ -58,6 +58,10 @@ module Types field :work_item_type, Types::WorkItems::TypeType, null: false, description: 'Type assigned to the work item.' + field :archived, GraphQL::Types::Boolean, null: false, + description: 'Whether the work item belongs to an archived project. Always false for group level work items.', + alpha: { milestone: '16.5' } + markdown_field :title_html, null: true markdown_field :description_html, null: true @@ -70,5 +74,11 @@ module Types def create_note_email object.creatable_note_email_address(context[:current_user]) end + + def archived + return false if object.project.blank? + + object.project.archived? + end end end diff --git a/app/graphql/types/work_items/widgets/hierarchy_type.rb b/app/graphql/types/work_items/widgets/hierarchy_type.rb index 4ec8ec84779..41c5af2ce63 100644 --- a/app/graphql/types/work_items/widgets/hierarchy_type.rb +++ b/app/graphql/types/work_items/widgets/hierarchy_type.rb @@ -20,6 +20,12 @@ module Types null: true, complexity: 5, description: 'Child work items.' + field :ancestors, ::Types::WorkItemType.connection_type, + null: true, complexity: 5, + description: 'Ancestors (parents) of the work item.', + extras: [:lookahead], + resolver: Resolvers::WorkItems::AncestorsResolver + field :has_children, GraphQL::Types::Boolean, null: false, description: 'Indicates if the work item has children.' diff --git a/app/graphql/types/work_items/widgets/notes_type.rb b/app/graphql/types/work_items/widgets/notes_type.rb index 7da2777beee..199001649bb 100644 --- a/app/graphql/types/work_items/widgets/notes_type.rb +++ b/app/graphql/types/work_items/widgets/notes_type.rb @@ -18,7 +18,8 @@ module Types field :discussions, Types::Notes::DiscussionType.connection_type, null: true, description: "Notes on this work item.", - resolver: Resolvers::WorkItems::WorkItemDiscussionsResolver + resolver: Resolvers::WorkItems::WorkItemDiscussionsResolver, + connection_extension: Gitlab::Graphql::Extensions::ForwardOnlyExternallyPaginatedArrayExtension end # rubocop:enable Graphql/AuthorizeTypes end diff --git a/app/helpers/access_tokens_helper.rb b/app/helpers/access_tokens_helper.rb index 44200e84afb..4bb6ae29151 100644 --- a/app/helpers/access_tokens_helper.rb +++ b/app/helpers/access_tokens_helper.rb @@ -5,7 +5,14 @@ module AccessTokensHelper include ApplicationHelper def scope_description(prefix) - prefix == :project_access_token ? [:doorkeeper, :project_access_token_scope_desc] : [:doorkeeper, :scope_desc] + case prefix + when :project_access_token + [:doorkeeper, :project_access_token_scope_desc] + when :group_access_token + [:doorkeeper, :group_access_token_scope_desc] + else + [:doorkeeper, :scope_desc] + end end def tokens_app_data diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index 5beefbb943c..531ea08791c 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -80,7 +80,7 @@ module AppearancesHelper add_gitlab_black_text = options[:add_gitlab_black_text] || false if current_appearance&.header_logo? - image_tag current_appearance.header_logo_path, class: 'brand-header-logo' + image_tag current_appearance.header_logo_path, class: 'brand-header-logo', alt: '' elsif add_gitlab_white_text render partial: 'shared/logo_with_white_text', formats: :svg elsif add_gitlab_black_text diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e3a630024d9..57937353955 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -123,7 +123,7 @@ module ApplicationHelper { page: body_data_page, page_type_id: controller.params[:id], - find_file: find_file_path, + find_file: find_file_path(ref_type: @ref_type), group: @group&.path, group_full_path: @group&.full_path }.merge(project_data) @@ -404,6 +404,10 @@ module ApplicationHelper end def add_page_specific_style(path, defer: true) + @already_added_styles ||= Set.new + return if @already_added_styles.include?(path) + + @already_added_styles.add(path) content_for :page_specific_styles do if defer stylesheet_link_tag_defer path @@ -468,7 +472,7 @@ module ApplicationHelper end def hidden_resource_icon(resource, css_class: nil) - issuable_title = _('This %{issuable} is hidden because its author has been banned') + issuable_title = _('This %{issuable} is hidden because its author has been banned.') case resource when Issue diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index ef91915ce38..58648a82487 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -282,6 +282,7 @@ module ApplicationSettingsHelper :external_pipeline_validation_service_timeout, :external_pipeline_validation_service_token, :external_pipeline_validation_service_url, + :failed_login_attempts_unlock_period_in_minutes, :first_day_of_week, :floc_enabled, :force_pages_access_control, @@ -314,12 +315,14 @@ module ApplicationSettingsHelper :jira_connect_application_key, :jira_connect_public_key_storage_enabled, :jira_connect_proxy_url, + :math_rendering_limits_enabled, :max_artifacts_size, :max_attachment_size, + :max_decompressed_archive_size, :max_export_size, :max_import_size, :max_import_remote_file_size, - :max_decompressed_archive_size, + :max_login_attempts, :max_pages_size, :max_pages_custom_domains_per_project, :max_terraform_state_size_bytes, @@ -507,7 +510,8 @@ module ApplicationSettingsHelper :allow_account_deletion, :gitlab_shell_operation_limit, :namespace_aggregation_schedule_lease_duration_in_seconds, - :ci_max_total_yaml_size_bytes + :ci_max_total_yaml_size_bytes, + :project_jobs_api_rate_limit ].tap do |settings| next if Gitlab.com? diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index b7acc562be5..fc157df3891 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -182,26 +182,6 @@ module AuthHelper current_user.allow_password_authentication_for_web? && !current_user.password_automatically_set? end - def google_tag_manager_enabled? - return false unless Gitlab.com? - - if Feature.enabled?(:gtm_nonce, type: :ops) - extra_config.has_key?('google_tag_manager_nonce_id') && - extra_config.google_tag_manager_nonce_id.present? - else - extra_config.has_key?('google_tag_manager_id') && - extra_config.google_tag_manager_id.present? - end - end - - def google_tag_manager_id - return unless google_tag_manager_enabled? - - return extra_config.google_tag_manager_nonce_id if Feature.enabled?(:gtm_nonce, type: :ops) - - extra_config.google_tag_manager_id - end - def auth_app_owner_text(owner) return unless owner diff --git a/app/helpers/blame_helper.rb b/app/helpers/blame_helper.rb index 56d651a8b65..f00493ddf2a 100644 --- a/app/helpers/blame_helper.rb +++ b/app/helpers/blame_helper.rb @@ -1,13 +1,6 @@ # frozen_string_literal: true module BlameHelper - BODY_FONT_SIZE = "0.875rem" - COMMIT_LINE_HEIGHT = 3 # 150% * 2 lines of text - COMMIT_PADDING = "10px" # 5px from both top and bottom - COMMIT_BLOCK_HEIGHT_EXP = "(#{BODY_FONT_SIZE} * #{COMMIT_LINE_HEIGHT}) + #{COMMIT_PADDING}" - CODE_LINE_HEIGHT = 1.1875 - CODE_PADDING = "20px" # 10px from both top and bottom - def age_map_duration(blame_groups, project) now = Time.zone.now start_date = blame_groups.map { |blame_group| blame_group[:commit].committed_date } @@ -32,14 +25,6 @@ module BlameHelper end end - def intrinsic_row_css(line_count) - # using rems here because the size of the row depends on the text size - # which can be customized via user agent styles and browser preferences - total_line_height_exp = "#{line_count * CODE_LINE_HEIGHT}rem + #{CODE_PADDING}" - row_height_exp = line_count == 1 ? COMMIT_BLOCK_HEIGHT_EXP : total_line_height_exp - "contain-intrinsic-size: 1px calc(#{row_height_exp})" - end - def blame_pages_streaming_url(id, project) namespace_project_blame_page_url(namespace_id: project.namespace, project_id: project, id: id, streaming: true) end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 6746e6549ec..0d5b8755a37 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -268,14 +268,6 @@ module BlobHelper }.compact end - def edit_modify_file_fork_params(action) - { - to: request.fullpath, - notice: edit_in_new_fork_notice_action(action), - notice_now: edit_in_new_fork_notice_now - } - end - def edit_fork_button_tag(common_classes, project, label, params, action = 'edit') fork_path = project_forks_path(project, namespace_key: current_user.namespace.id, continue: params) @@ -322,11 +314,6 @@ module BlobHelper @project.team.human_max_access(current_user&.id).try(:downcase) end - def editing_ci_config? - @path.to_s.end_with?(Ci::Pipeline::CONFIG_EXTENSION) || - @path.to_s == @project.ci_config_path_or_default - end - def vue_blob_app_data(project, blob, ref) { blob_path: blob.path, diff --git a/app/helpers/ci/builds_helper.rb b/app/helpers/ci/builds_helper.rb index 8a00c0f3eb0..001b316fcf2 100644 --- a/app/helpers/ci/builds_helper.rb +++ b/app/helpers/ci/builds_helper.rb @@ -9,15 +9,6 @@ module Ci build_class.join(' ') end - def javascript_build_options - { - page_path: project_job_path(@project, @build), - build_status: @build.status, - build_stage: @build.stage_name, - log_state: '' - } - end - def build_failed_issue_options { title: _("Job Failed #%{build_id}") % { build_id: @build.id }, diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb index 991b1f4d74e..216a8bc8fa1 100644 --- a/app/helpers/ci/jobs_helper.rb +++ b/app/helpers/ci/jobs_helper.rb @@ -5,15 +5,13 @@ module Ci def jobs_data(project, build) { "endpoint" => project_job_path(project, build, format: :json), + "page_path" => project_job_path(project, build), "project_path" => project.full_path, "artifact_help_url" => help_page_path('user/gitlab_com/index.md', anchor: 'gitlab-cicd'), "deployment_help_url" => help_page_path('user/project/clusters/deploy_to_cluster.md', anchor: 'troubleshooting'), "runner_settings_url" => project_runners_path(build.project, anchor: 'js-runners-settings'), - "page_path" => project_job_path(project, build), "build_status" => build.status, "build_stage" => build.stage_name, - "log_state" => '', - "build_options" => javascript_build_options, "retry_outdated_job_docs_url" => help_page_path('ci/pipelines/settings', anchor: 'retry-outdated-jobs') } end diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb index a034e4331c0..510c7cd5fb6 100644 --- a/app/helpers/ci/pipelines_helper.rb +++ b/app/helpers/ci/pipelines_helper.rb @@ -78,10 +78,7 @@ module Ci params: params.to_json, artifacts_endpoint: downloadable_artifacts_project_pipeline_path(project, artifacts_endpoint_placeholder, format: :json), artifacts_endpoint_placeholder: artifacts_endpoint_placeholder, - pipeline_schedule_url: pipeline_schedules_path(project), - empty_state_svg_path: image_path('illustrations/empty-state/empty-pipeline-md.svg'), - error_state_svg_path: image_path('illustrations/pipelines_failed.svg'), - no_pipelines_svg_path: image_path('illustrations/empty-state/empty-pipeline-md.svg'), + pipeline_schedules_path: pipeline_schedules_path(project), can_create_pipeline: can?(current_user, :create_pipeline, project).to_s, new_pipeline_path: can?(current_user, :create_pipeline, project) && new_project_pipeline_path(project), ci_lint_path: can?(current_user, :create_pipeline, project) && project_ci_lint_path(project), diff --git a/app/helpers/ci/status_helper.rb b/app/helpers/ci/status_helper.rb index 5d526a6abb6..86f48b51f76 100644 --- a/app/helpers/ci/status_helper.rb +++ b/app/helpers/ci/status_helper.rb @@ -72,20 +72,19 @@ module Ci status, path, tooltip_placement: tooltip_placement, - icon_size: 24) + icon_size: 16) end def render_status_with_link(status, path = nil, type: _('pipeline'), tooltip_placement: 'left', cssclass: '', container: 'body', icon_size: 16) - klass = "ci-status-link #{ci_icon_class_for_status(status)} d-inline-flex #{cssclass}" + variant = badge_variant(status) + klass = "ci-status-link #{ci_icon_class_for_status(status)} d-inline-flex gl-line-height-1 #{cssclass}" title = "#{type.titleize}: #{ci_label_for_status(status)}" - data = { toggle: 'tooltip', placement: tooltip_placement, container: container } + data = { toggle: 'tooltip', placement: tooltip_placement, container: container, testid: 'ci-status-badge-legacy' } + badge_classes = 'gl-px-2 gl-ml-3' - if path - link_to ci_icon_for_status(status, size: icon_size), path, - class: klass, title: title, data: data - else + gl_badge_tag(variant: variant, size: :md, href: path, class: badge_classes, title: title, data: data) do content_tag :span, ci_icon_for_status(status, size: icon_size), - class: klass, title: title, data: data + class: klass end end @@ -118,5 +117,24 @@ module Ci translation = "CiStatusLabel|#{label}" s_(translation) end + + def badge_variant(status) + variant = detailed_status?(status) ? status.group : status.dasherize + + case variant + when 'success' + :success + when 'success-with-warnings', 'pending' + :warning + when 'failed' + :danger + when 'running' + :info + when 'canceled', 'manual' + :neutral + else + :muted + end + end end end diff --git a/app/helpers/ci/triggers_helper.rb b/app/helpers/ci/triggers_helper.rb index 01555b6e2cc..56b64d6049b 100644 --- a/app/helpers/ci/triggers_helper.rb +++ b/app/helpers/ci/triggers_helper.rb @@ -9,7 +9,7 @@ module Ci::TriggersHelper end end - def service_trigger_url(service) - "#{Settings.gitlab.url}/api/v4/projects/#{service.project_id}/services/#{service.to_param}/trigger" + def integration_trigger_url(integration) + "#{Settings.gitlab.url}/api/v4/projects/#{integration.project_id}/integrations/#{integration.to_param}/trigger" end end diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 42871dcc56f..6ffef1b612b 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -42,7 +42,7 @@ module CommitsHelper crumbs = content_tag(:li, class: 'breadcrumb-item') do link_to( @project.path, - project_commits_path(@project, @ref) + project_commits_path(@project, @ref, ref_type: @ref_type) ) end @@ -56,7 +56,8 @@ module CommitsHelper part, project_commits_path( @project, - tree_join(@ref, parts[0..i].join('/')) + tree_join(@ref, parts[0..i].join('/')), + ref_type: @ref_type ) ) end diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index 9a78d4d9ad5..9031d0556da 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -138,24 +138,18 @@ module DiffHelper def submodule_diff_compare_link(diff_file) compare_url = submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository, diff_file)&.compare + return '' unless compare_url - link = "" + link_text = [ + _('Compare'), + ' ', + content_tag(:span, Commit.truncate_sha(diff_file.old_blob.id), class: 'commit-sha'), + '...', + content_tag(:span, Commit.truncate_sha(diff_file.blob.id), class: 'commit-sha') + ].join('').html_safe - if compare_url - - link_text = [ - _('Compare'), - ' ', - content_tag(:span, Commit.truncate_sha(diff_file.old_blob.id), class: 'commit-sha'), - '...', - content_tag(:span, Commit.truncate_sha(diff_file.blob.id), class: 'commit-sha') - ].join('').html_safe - - tooltip = _('Compare submodule commit revisions') - link = content_tag(:span, link_to(link_text, compare_url, class: 'btn gl-button has-tooltip', title: tooltip), class: 'submodule-compare') - end - - link + tooltip = _('Compare submodule commit revisions') + link_button_to link_text, compare_url, class: 'has-tooltip submodule-compare', title: tooltip end def diff_file_blob_raw_url(diff_file, only_path: false) @@ -270,11 +264,6 @@ module DiffHelper toggle_whitespace_link(url, options) end - def diff_merge_request_whitespace_link(project, merge_request, options) - url = diffs_project_merge_request_path(project, merge_request, params_with_whitespace) - toggle_whitespace_link(url, options) - end - def diff_compare_whitespace_link(project, from, to, options) url = project_compare_path(project, from, to, params_with_whitespace) toggle_whitespace_link(url, options) @@ -285,9 +274,8 @@ module DiffHelper end def toggle_whitespace_link(url, options) - options[:class] = [*options[:class], 'btn gl-button btn-default'].join(' ') toggle_text = hide_whitespace? ? s_('Diffs|Show whitespace changes') : s_('Diffs|Hide whitespace changes') - link_to toggle_text, url, class: options[:class] + link_button_to toggle_text, url, class: options[:class] end def code_navigation_path(diffs) diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index ce18bedd25f..cc91b70758f 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -36,7 +36,7 @@ module DropdownsHelper output << dropdown_filter(options[:placeholder]) end - output << content_tag(:div, data: { qa_selector: "dropdown_list_content" }, class: "dropdown-content #{options[:content_class] if options.key?(:content_class)}") do + output << content_tag(:div, data: { testid: "dropdown-list-content" }, class: "dropdown-content #{options[:content_class] if options.key?(:content_class)}") do capture(&block) if block && !options.key?(:footer_content) end diff --git a/app/helpers/groups/observability_helper.rb b/app/helpers/groups/observability_helper.rb deleted file mode 100644 index 7661817da7b..00000000000 --- a/app/helpers/groups/observability_helper.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -module Groups - module ObservabilityHelper - ACTION_TO_PATH = { - 'dashboards' => { - path: '/', - title: -> { s_('Observability|Dashboards') } - }, - 'manage' => { - path: '/dashboards', - title: -> { s_('Observability|Manage dashboards') } - }, - 'explore' => { - path: '/explore', - title: -> { s_('Observability|Explore telemetry data') } - }, - 'datasources' => { - path: '/datasources', - title: -> { s_('Observability|Data sources') } - } - }.freeze - - def observability_iframe_src(group) - Gitlab::Observability.build_full_url(group, params[:observability_path], - observability_config_for(params).fetch(:path)) - end - - def observability_page_title - observability_config_for(params).fetch(:title).call - end - - private - - def observability_config_for(params) - ACTION_TO_PATH.fetch(params[:action], ACTION_TO_PATH['dashboards']) - end - end -end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index e552b01f7ba..f48157cb65a 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -174,7 +174,9 @@ module GroupsHelper end def show_group_readme?(group) - group.group_readme + return false unless group.group_readme + + can?(current_user, :read_code, group.readme_project) end def group_settings_readme_app_data(group) @@ -186,7 +188,7 @@ module GroupsHelper } end - def enabled_git_access_protocol_options_for_group + def enabled_git_access_protocol_options_for_group(_) case ::Gitlab::CurrentSettings.enabled_git_access_protocol when nil, "" [[_("Both SSH and HTTP(S)"), "all"], [_("Only SSH"), "ssh"], [_("Only HTTP(S)"), "http"]] @@ -197,6 +199,14 @@ module GroupsHelper end end + def new_custom_emoji_path(group) + return unless Feature.enabled?(:custom_emoji) + return unless group + return unless can?(current_user, :create_custom_emoji, group) + + new_group_custom_emoji_path(group) + end + private def group_title_link(group, hidable: false, show_avatar: false, for_dropdown: false) diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index 696790b9dcb..2582d6fcc34 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -7,10 +7,7 @@ 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'), 'sign-in-path' => new_session_path(current_user), - 'user-preferences-path' => profile_preferences_path, - 'editor-font-src-url' => font_url('gitlab-mono/GitLabMono.woff2'), - 'editor-font-family' => 'GitLab Mono', - 'editor-font-format' => 'woff2' + 'user-preferences-path' => profile_preferences_path }.merge(use_new_web_ide? ? new_ide_data(project: project) : legacy_ide_data(project: project)) return base_data unless project @@ -29,6 +26,28 @@ module IdeHelper private + def new_ide_fonts + { + fallback_font_family: 'monospace', + font_faces: [{ + family: 'GitLab Mono', + display: 'block', + src: [{ + url: font_url('gitlab-mono/GitLabMono.woff2'), + format: 'woff2' + }] + }, { + family: 'GitLab Mono', + display: 'block', + style: 'italic', + src: [{ + url: font_url('gitlab-mono/GitLabMono-Italic.woff2'), + format: 'woff2' + }] + }] + } + end + def new_ide_code_suggestions_data {} end @@ -38,7 +57,8 @@ module IdeHelper 'project-path' => project&.path_with_namespace, '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') + 'ide-remote-path' => ide_remote_path(remote_host: ':remote_host', remote_path: ':remote_path'), + 'editor-font' => new_ide_fonts.to_json }.merge(new_ide_code_suggestions_data) end diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb index a88be976337..510561ec614 100644 --- a/app/helpers/integrations_helper.rb +++ b/app/helpers/integrations_helper.rb @@ -30,10 +30,6 @@ module IntegrationsHelper _("Alert") when "incident" _("Incident") - when "group_mention" - _("Group mention in public") - when "group_confidential_mention" - _("Group mention in private") end end # rubocop:enable Metrics/CyclomaticComplexity @@ -295,10 +291,6 @@ module IntegrationsHelper s_("ProjectService|Trigger event when a new, unique alert is recorded.") when "incident", "incident_events" s_("ProjectService|Trigger event when an incident is created.") - when "group_mention" - s_("ProjectService|Trigger event when a group is mentioned in a public context.") - when "group_confidential_mention" - s_("ProjectService|Trigger event when a group is mentioned in a confidential context.") when "build_events" s_("ProjectService|Trigger event when a build is created.") when "archive_trace_events" diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 7f948db2f71..f2f20fa1b50 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -177,7 +177,6 @@ module IssuablesHelper markdownPreviewPath: preview_markdown_path(parent, target_type: issuable.model_name, target_id: issuable.iid), markdownDocsPath: help_page_path('user/markdown'), lockVersion: issuable.lock_version, - state: issuable.state, issuableTemplateNamesPath: template_names_path(parent, issuable), initialTitleHtml: markdown_field(issuable, :title), initialTitleText: issuable.title, @@ -231,22 +230,6 @@ module IssuablesHelper end end - def state_name_with_icon(issuable) - if issuable.is_a?(MergeRequest) - if issuable.open? - [_("Open"), "merge-request-open"] - elsif issuable.merged? - [_("Merged"), "merge"] - else - [_("Closed"), "merge-request-close"] - end - elsif issuable.open? - [_("Open"), "issues"] - else - [_("Closed"), "issue-closed"] - end - end - def issuable_type_selector_data(issuable) { selected_type: issuable.issue_type, @@ -374,7 +357,6 @@ module IssuablesHelper issuableId: issuable.id, issueType: issuable.issue_type, isHidden: issue_hidden?(issuable), - sentryIssueIdentifier: SentryIssue.find_by(issue: issuable)&.sentry_issue_identifier, # rubocop:disable CodeReuse/ActiveRecord zoomMeetingUrl: ZoomMeeting.canonical_meeting_url(issuable), **incident_only_initial_data(issuable), **issue_header_data(issuable), diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 06eb3fcc233..131cd7cd969 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -78,10 +78,6 @@ module MergeRequestsHelper .execute(include_routes: true) end - def merge_request_button_visibility(merge_request, closed) - return 'hidden' if merge_request_button_hidden?(merge_request, closed) - end - def merge_request_button_hidden?(merge_request, closed) merge_request.closed? == closed || (merge_request.merged? == closed && !merge_request.closed?) || merge_request.closed_or_merged_without_fork? end @@ -150,12 +146,6 @@ module MergeRequestsHelper end end - def toggle_draft_merge_request_path(issuable) - wip_event = issuable.draft? ? 'ready' : 'draft' - - issuable_path(issuable, { merge_request: { wip_event: wip_event } }) - end - def user_merge_requests_counts @user_merge_requests_counts ||= begin assigned_count = assigned_issuables_count(:merge_requests) @@ -185,6 +175,10 @@ module MergeRequestsHelper Feature.enabled?(:moved_mr_sidebar, @project) end + def notifications_todos_buttons_enabled? + Feature.enabled?(:notifications_todos_buttons, @project) + end + def diffs_tab_pane_data(project, merge_request, params) { "is-locked": merge_request.discussion_locked?, @@ -207,7 +201,8 @@ module MergeRequestsHelper source_project_default_url: merge_request.source_project && default_url_to_repo(merge_request.source_project), source_project_full_path: merge_request.source_project&.full_path, is_forked: project.forked?.to_s, - new_comment_template_path: profile_comment_templates_path + new_comment_template_path: profile_comment_templates_path, + iid: merge_request.iid } end @@ -273,14 +268,14 @@ module MergeRequestsHelper '' end - link_to branch, branch_path, title: branch_title, class: 'gl-text-blue-500! gl-font-monospace gl-bg-blue-50 gl-rounded-base gl-font-sm gl-px-2 gl-display-inline-block gl-text-truncate gl-max-w-26 gl-mx-2' + link_to branch, branch_path, title: branch_title, class: 'ref-container gl-display-inline-block gl-text-truncate gl-max-w-26 gl-mx-2' end def merge_request_header(project, merge_request) link_to_author = link_to_member(project, merge_request.author, size: 24, extra_class: 'gl-font-weight-bold gl-mr-2', avatar: false) copy_button = clipboard_button(text: merge_request.source_branch, title: _('Copy branch name'), class: 'gl-display-none! gl-md-display-inline-block! js-source-branch-copy') - target_branch = link_to merge_request.target_branch, project_tree_path(merge_request.target_project, merge_request.target_branch), title: merge_request.target_branch, class: 'gl-text-blue-500! gl-font-monospace gl-bg-blue-50 gl-rounded-base gl-font-sm gl-px-2 gl-display-inline-block gl-text-truncate gl-max-w-26 gl-mx-2' + target_branch = link_to merge_request.target_branch, project_tree_path(merge_request.target_project, merge_request.target_branch), title: merge_request.target_branch, class: 'ref-container gl-display-inline-block gl-text-truncate gl-max-w-26 gl-mx-2' _('%{author} requested to merge %{source_branch} %{copy_button} into %{target_branch} %{created_at}').html_safe % { author: link_to_author.html_safe, source_branch: merge_request_source_branch(merge_request).html_safe, copy_button: copy_button.html_safe, target_branch: target_branch.html_safe, created_at: time_ago_with_tooltip(merge_request.created_at, html_class: 'gl-display-inline-block').html_safe } end diff --git a/app/helpers/organizations/organization_helper.rb b/app/helpers/organizations/organization_helper.rb index 6b5c4342c5c..5d89bb93000 100644 --- a/app/helpers/organizations/organization_helper.rb +++ b/app/helpers/organizations/organization_helper.rb @@ -16,10 +16,24 @@ module Organizations }.merge(shared_groups_and_projects_app_data).to_json end + def organization_new_app_data + { + organizations_path: organizations_path, + root_url: root_url + }.to_json + end + def organization_groups_and_projects_app_data shared_groups_and_projects_app_data.to_json end + def organization_index_app_data + { + new_organization_url: new_organization_path, + organizations_empty_state_svg_path: image_path('illustrations/empty-state/empty-organizations-md.svg') + } + end + private def shared_groups_and_projects_app_data diff --git a/app/helpers/projects/ml/experiments_helper.rb b/app/helpers/projects/ml/experiments_helper.rb index d5b2c3cd36a..6c7b6eb6fbc 100644 --- a/app/helpers/projects/ml/experiments_helper.rb +++ b/app/helpers/projects/ml/experiments_helper.rb @@ -14,17 +14,17 @@ module Projects Gitlab::Json.generate(data) end - def candidates_table_items(candidates, user) + def candidates_table_items(candidates, current_user) 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)] }, - ci_job: job_info(candidate, user), + ci_job: job_info(candidate, current_user), artifact: link_to_artifact(candidate), details: link_to_details(candidate), name: candidate.name, created_at: candidate.created_at, - user: user_info(candidate) + user: user_info(candidate, current_user) } end @@ -87,8 +87,13 @@ module Projects project_ml_experiment_path(project, experiment.iid) end - def user_info(candidate) - user = candidate.user + def user_info(candidate, current_user) + user = + if candidate.from_ci? + candidate.ci_build.user if can?(current_user, :read_build, candidate.ci_build) + else + candidate.user + end return unless user.present? diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index e45b38f2266..04fe0a4450c 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -195,27 +195,6 @@ module ProjectsHelper { branch_name: tag.strong(truncate(sanitize(branch_name))), link_to_autodeploy_doc: link_to_autodeploy_doc } end - def project_list_cache_key(project, pipeline_status: true) - key = [ - project.star_count, - project.route.cache_key, - project.cache_key, - project.last_activity_date, - controller.controller_name, - controller.action_name, - Gitlab::CurrentSettings.cache_key, - "cross-project:#{can?(current_user, :read_cross_project)}", - max_project_member_access_cache_key(project), - pipeline_status, - Gitlab::I18n.locale, - 'v2.6' - ] - - key << pipeline_status_cache_key(project.pipeline_status) if pipeline_status && project.pipeline_status.has_status? - - key - end - def load_pipeline_status(projects) Gitlab::Cache::Ci::ProjectPipelineStatus .load_in_batch_for_projects(projects) @@ -252,8 +231,6 @@ module ProjectsHelper end def show_mobile_devops_project_promo?(project) - return false unless ::Feature.enabled?(:mobile_devops_projects_promo, project) - return false unless (project.project_setting.target_platforms & ::ProjectSetting::ALLOWED_TARGET_PLATFORMS).any? cookies["hide_mobile_devops_promo_#{project.id}".to_sym].blank? @@ -373,18 +350,6 @@ module ProjectsHelper false end - def grafana_integration_url - @project.grafana_integration&.grafana_url - end - - def grafana_integration_masked_token - @project.grafana_integration&.masked_token - end - - def grafana_integration_enabled? - @project.grafana_integration&.enabled? - end - def project_license_name(project) key = "project:#{project.id}:license_name" @@ -479,10 +444,6 @@ module ProjectsHelper configure_oauth_import_message('Bitbucket', help_page_path("integration/bitbucket")) end - def import_from_gitlab_message - configure_oauth_import_message('GitLab.com', help_page_path("integration/gitlab")) - end - def show_inactive_project_deletion_banner?(project) return false unless project.present? && project.saved? return false unless delete_inactive_projects? @@ -674,30 +635,6 @@ module ProjectsHelper end end - def project_last_activity(project) - if project.last_activity_at - time_ago_with_tooltip(project.last_activity_at, placement: 'bottom', html_class: 'last_activity_time_ago') - else - s_("ProjectLastActivity|Never") - end - end - - def project_status_css_class(status) - case status - when "started" - "table-active" - when "failed" - "table-danger" - when "finished" - "table-success" - end - end - - def readme_cache_key - sha = @project.commit.try(:sha) || 'nil' - [@project.full_path, sha, "readme"].join('-') - end - def current_ref @ref || @repository.try(:root_ref) end @@ -756,13 +693,13 @@ module ProjectsHelper end end - def find_file_path + def find_file_path(ref_type: nil) return unless @project && !@project.empty_repo? return unless can?(current_user, :read_code, @project) ref = @ref || @project.repository.root_ref - project_find_file_path(@project, ref) + project_find_file_path(@project, ref, ref_type: ref_type) end def can_show_last_commit_in_list?(project) diff --git a/app/helpers/registrations_helper.rb b/app/helpers/registrations_helper.rb index c2c142bca4d..363c38ffe59 100644 --- a/app/helpers/registrations_helper.rb +++ b/app/helpers/registrations_helper.rb @@ -16,6 +16,9 @@ module RegistrationsHelper end # overridden in EE + def oauth_tracking_label; end + + # overridden in EE def register_omniauth_params(_local_assigns) {} end diff --git a/app/helpers/resource_events/abuse_report_events_helper.rb b/app/helpers/resource_events/abuse_report_events_helper.rb index 8adbc891184..207ec73454b 100644 --- a/app/helpers/resource_events/abuse_report_events_helper.rb +++ b/app/helpers/resource_events/abuse_report_events_helper.rb @@ -10,6 +10,8 @@ module ResourceEvents s_('AbuseReportEvent|Successfully blocked the user') when 'delete_user' s_('AbuseReportEvent|Successfully scheduled the user for deletion') + when 'trust_user' + s_('AbuseReportEvent|Successfully trusted the user') when 'close_report' s_('AbuseReportEvent|Successfully closed the report') when 'ban_user_and_close_report' @@ -18,6 +20,8 @@ module ResourceEvents s_('AbuseReportEvent|Successfully blocked the user and closed the report') when 'delete_user_and_close_report' s_('AbuseReportEvent|Successfully scheduled the user for deletion and closed the report') + when 'trust_user_and_close_report' + s_('AbuseReportEvent|Successfully trusted the user and closed the report') end end end diff --git a/app/helpers/routing/projects_helper.rb b/app/helpers/routing/projects_helper.rb index 06de9022be4..1f00d283a30 100644 --- a/app/helpers/routing/projects_helper.rb +++ b/app/helpers/routing/projects_helper.rb @@ -43,10 +43,12 @@ module Routing end def work_item_url(entity, *args) - if entity.project.present? - project_work_items_url(entity.project, entity.iid, *args) + return group_work_item_url(entity.namespace, entity.iid, *args) unless entity.project.present? + + if use_issue_path?(entity) + project_issue_url(entity.project, entity.iid, *args) else - group_work_item_url(entity.namespace, entity.iid, *args) + project_work_item_url(entity.project, entity.iid, *args) end end @@ -97,6 +99,10 @@ module Routing issue.issue_type == 'task' end + + def use_issue_path?(work_item) + work_item.issue_type == 'issue' + end end end diff --git a/app/helpers/safe_format_helper.rb b/app/helpers/safe_format_helper.rb index 71bfc9ecb40..9f8c5082c26 100644 --- a/app/helpers/safe_format_helper.rb +++ b/app/helpers/safe_format_helper.rb @@ -25,8 +25,8 @@ module SafeFormatHelper # Use `Kernel.format` to avoid conflicts with ViewComponent's `format`. Kernel.format( - html_escape_once(format), - args.transform_values { |value| html_escape(value) } + ERB::Util.html_escape_once(format), + args.transform_values { |value| ERB::Util.html_escape(value) } ).html_safe end diff --git a/app/helpers/sidekiq_helper.rb b/app/helpers/sidekiq_helper.rb deleted file mode 100644 index 21aa82aff1c..00000000000 --- a/app/helpers/sidekiq_helper.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module SidekiqHelper - SIDEKIQ_PS_REGEXP = %r{\A - (?<pid>\d+)\s+ - (?<cpu>[\d\.,]+)\s+ - (?<mem>[\d\.,]+)\s+ - (?<state>[DIEKNRSTVWXZLpsl\+<>/\d]+)\s+ - (?<start>.+?)\s+ - (?<command>(?:ruby\d+:\s+)?sidekiq.*\].*) - \z}x - - def parse_sidekiq_ps(line) - match = line.strip.match(SIDEKIQ_PS_REGEXP) - match ? match[1..6] : Array.new(6, '?') - end -end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 1405bc7be37..94445564c22 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -64,15 +64,6 @@ module SortingHelper options end - def forks_sort_options_hash - { - sort_value_recently_created => sort_title_created_date, - sort_value_oldest_created => sort_title_created_date, - sort_value_latest_activity => sort_title_latest_activity, - sort_value_oldest_activity => sort_title_latest_activity - } - end - def forks_reverse_sort_options_hash { sort_value_recently_created => sort_value_oldest_created, @@ -93,12 +84,6 @@ module SortingHelper } end - def subgroups_sort_options_hash - groups_sort_options_hash.merge( - sort_value_stars_desc => sort_title_most_stars - ) - end - def admin_groups_sort_options_hash groups_sort_options_hash.merge( sort_value_largest_group => sort_title_largest_group @@ -199,19 +184,6 @@ module SortingHelper }.merge(issuable_sort_option_overrides) end - def audit_logs_sort_order_hash - { - sort_value_recently_created => sort_title_recently_created, - sort_value_oldest_created => sort_title_oldest_created - } - end - - def issuable_sort_option_title(sort_value) - sort_value = issuable_sort_option_overrides[sort_value] || sort_value - - sort_options_hash[sort_value] - end - def issuable_sort_options(viewing_issues, viewing_merge_requests) options = [ { value: sort_value_priority, text: sort_title_priority, href: page_filter_path(sort: sort_value_priority) }, @@ -321,17 +293,6 @@ module SortingHelper } end - def packages_sort_option_title(sort_value) - packages_sort_options_hash[sort_value] || sort_title_created_date - end - - def packages_sort_direction_button(sort_value) - reverse_sort = packages_reverse_sort_order_hash[sort_value] - url = package_sort_path(sort: reverse_sort) - - sort_direction_button(url, reverse_sort, sort_value) - end - def forks_sort_direction_button(sort_value, without = [:state, :scope, :label_name, :milestone_id, :assignee_id, :author_id]) reverse_sort = forks_reverse_sort_options_hash[sort_value] url = page_filter_path(sort: reverse_sort, without: without) diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index 0d885621b6c..d053aeb7bfe 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -31,6 +31,9 @@ module TodosHelper s_("Todos|has requested access to %{what} %{which}"), what: _(todo.member_access_type), which: _(todo.target.name) ) when Todo::REVIEW_SUBMITTED then s_('Todos|reviewed your merge request') + when Todo::OKR_CHECKIN_REQUESTED then format( + s_("Todos|requested an OKR update for %{what}"), what: todo.target.title + ) end end @@ -163,6 +166,10 @@ module TodosHelper todos_filter_params.values.none? end + def todos_has_filtered_results? + params[:group_id] || params[:project_id] || params[:author_id] || params[:type] || params[:action_id] + end + def no_todos_messages [ s_('Todos|Good job! Looks like you don\'t have anything left on your To-Do List'), diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 30f8f6fdfe5..a892b6e6ac6 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -67,6 +67,8 @@ module UsersHelper project_ids = projects.pluck(:id) # rubocop: enable CodeReuse/ActiveRecord + preload_project_associations(projects) + Preloaders::UserMaxAccessLevelInProjectsPreloader .new(project_ids, current_user) .execute @@ -371,6 +373,10 @@ module UsersHelper def saved_replies_enabled? Feature.enabled?(:saved_replies, current_user) end + + def preload_project_associations(_) + # Overridden in EE + end end UsersHelper.prepend_mod_with('UsersHelper') diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb index b2b8ca2a120..bd63381e9d1 100644 --- a/app/helpers/wiki_helper.rb +++ b/app/helpers/wiki_helper.rb @@ -22,9 +22,7 @@ module WikiHelper end def wiki_sidebar_toggle_button - content_tag :button, class: 'gl-button btn btn-default btn-icon sidebar-toggle js-sidebar-wiki-toggle', role: 'button', type: 'button' do - sprite_icon('chevron-double-lg-left') - end + render Pajamas::ButtonComponent.new(icon: 'chevron-double-lg-left', button_options: { class: 'sidebar-toggle js-sidebar-wiki-toggle' }) end # Produces a pure text breadcrumb for a given page. @@ -60,17 +58,14 @@ module WikiHelper end def wiki_sort_controls(wiki, direction) - link_class = 'gl-button btn btn-default btn-icon has-tooltip reverse-sort-btn rspec-reverse-sort' + link_class = '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') link_options = { action: :pages, direction: reversed_direction } - link_to(wiki_path(wiki, **link_options), - type: 'button', class: link_class, title: title) do - sprite_icon("sort-#{icon_class}") - end + render Pajamas::ButtonComponent.new(href: wiki_path(wiki, **link_options), icon: "sort-#{icon_class}", button_options: { class: link_class, title: title }) end def wiki_sort_title(key) diff --git a/app/helpers/work_items_helper.rb b/app/helpers/work_items_helper.rb index 1969c98de8b..d2e3d41377a 100644 --- a/app/helpers/work_items_helper.rb +++ b/app/helpers/work_items_helper.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true module WorkItemsHelper - def work_items_index_data(project) + def work_items_index_data(resource_parent) { - full_path: project.full_path, - issues_list_path: project_issues_path(project), + full_path: resource_parent.full_path, + issues_list_path: + resource_parent.is_a?(Group) ? issues_group_path(resource_parent) : project_issues_path(resource_parent), register_path: new_user_registration_path(redirect_to_referer: 'yes'), sign_in_path: new_session_path(:user, redirect_to_referer: 'yes'), new_comment_template_path: profile_comment_templates_path, diff --git a/app/mailers/emails/in_product_marketing.rb b/app/mailers/emails/in_product_marketing.rb deleted file mode 100644 index 92743dc1926..00000000000 --- a/app/mailers/emails/in_product_marketing.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -module Emails - module InProductMarketing - FROM_ADDRESS = 'GitLab <team@gitlab.com>' - CUSTOM_HEADERS = { - from: FROM_ADDRESS, - reply_to: FROM_ADDRESS, - 'X-Mailgun-Track' => 'yes', - 'X-Mailgun-Track-Clicks' => 'yes', - 'X-Mailgun-Track-Opens' => 'yes', - 'X-Mailgun-Tag' => 'marketing' - }.freeze - - def build_ios_app_guide_email(recipient_email) - @message = ::Gitlab::Email::Message::BuildIosAppGuide.new - - mail_to(to: recipient_email, subject: @message.subject_line) - end - - private - - def mail_to(to:, subject:) - custom_headers = Gitlab.com? ? CUSTOM_HEADERS : {} - mail_with_locale(to: to, subject: subject, **custom_headers) do |format| - format.html do - @message.format = :html - - render layout: 'in_product_marketing_mailer' - end - - format.text do - @message.format = :text - - render layout: nil - end - end - end - end -end - -Emails::InProductMarketing.prepend_mod diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index a9e1efbdd5d..2be4cdf734a 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -65,11 +65,13 @@ module Emails @token_names = token_names @days_to_expire = PersonalAccessToken::DAYS_TO_EXPIRE @resource = resource - @target_url = if resource.is_a?(Group) - group_settings_access_tokens_url(resource) - else - project_settings_access_tokens_url(resource) - end + if resource.is_a?(Group) + @target_url = group_settings_access_tokens_url(resource) + @reason_text = _('You are receiving this email because you are an Owner of the Group.') + else + @target_url = project_settings_access_tokens_url(resource) + @reason_text = _('You are receiving this email because you are a Maintainer of the Project.') + end mail_with_locale( to: recipient.notification_email_or_default, @@ -100,7 +102,7 @@ module Emails @target_url = profile_personal_access_tokens_url @days_to_expire = PersonalAccessToken::DAYS_TO_EXPIRE - mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your personal access tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire })) + email_with_layout(to: @user.notification_email_or_default, subject: subject(_("Your personal access tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire })) end def access_token_expired_email(user, token_names = []) @@ -121,7 +123,7 @@ module Emails @target_url = profile_personal_access_tokens_url @source = source - mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("A personal access token has been revoked"))) + email_with_layout(to: @user.notification_email_or_default, subject: subject(_("Your personal access token has been revoked"))) end def ssh_key_expired_email(user, fingerprints) @@ -170,7 +172,7 @@ module Emails @user = user - mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Two-factor authentication disabled"))) + email_with_layout(to: @user.notification_email_or_default, subject: subject(_("Two-factor authentication disabled"))) end def new_email_address_added_email(user, email) diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 77d32a55941..2f90579a5c2 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -22,7 +22,6 @@ class Notify < ApplicationMailer include Emails::Groups include Emails::Reviews include Emails::ServiceDesk - include Emails::InProductMarketing include Emails::AdminNotification include Emails::IdentityVerification include Emails::Imports diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index 638df56b770..6548b6d1088 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -81,6 +81,10 @@ class NotifyPreview < ActionMailer::Preview Notify.access_token_revoked_email(user, 'token_name').message end + def access_token_about_to_expire_email + Notify.access_token_about_to_expire_email(user, ['%w', '%w']).message + end + def ssh_key_expired_email fingerprints = [] Notify.ssh_key_expired_email(user, fingerprints).message @@ -222,6 +226,10 @@ class NotifyPreview < ActionMailer::Preview Notify.two_factor_otp_attempt_failed_email(user, '127.0.0.1').message end + def disabled_two_factor_email + Notify.disabled_two_factor_email(user).message + end + def new_email_address_added_email Notify.new_email_address_added_email(user, 'someone@gitlab.com').message end diff --git a/app/models/ability.rb b/app/models/ability.rb index d8510524c1f..b8433191d84 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -166,3 +166,5 @@ class Ability end end end + +Ability.prepend_mod_with('AbilityPrepend') diff --git a/app/models/abuse/reports/user_mention.rb b/app/models/abuse/reports/user_mention.rb new file mode 100644 index 00000000000..e8091089ede --- /dev/null +++ b/app/models/abuse/reports/user_mention.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Abuse + module Reports + class UserMention < UserMention + self.table_name = 'abuse_report_user_mentions' + + belongs_to :abuse_report, optional: false + belongs_to :note, optional: false + end + end +end diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index bf25c539830..872dedf07b1 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -6,6 +6,8 @@ class AbuseReport < ApplicationRecord include Gitlab::FileTypeDetection include WithUploads include Gitlab::Utils::StrongMemoize + include Mentionable + include Noteable MAX_CHAR_LIMIT_URL = 512 MAX_FILE_SIZE = 1.megabyte @@ -23,6 +25,9 @@ class AbuseReport < ApplicationRecord has_many :abuse_events, class_name: 'Abuse::Event', inverse_of: :abuse_report + has_many :notes, as: :noteable + has_many :user_mentions, class_name: 'Abuse::Reports::UserMention' + validates :reporter, presence: true, on: :create validates :user, presence: true, on: :create validates :message, presence: true @@ -158,6 +163,10 @@ class AbuseReport < ApplicationRecord Project.find_by_full_path(route_hash.values_at(:namespace_id, :project_id).join('/')) end + def group + Group.find_by_full_path(route_hash[:group_id]) + end + def route_hash match = Rails.application.routes.recognize_path(reported_from_url) return {} if match[:unmatched_route].present? @@ -200,7 +209,7 @@ class AbuseReport < ApplicationRecord format(_('contains URLs that exceed the %{limit} character limit'), limit: MAX_CHAR_LIMIT_URL) ) end - rescue ::Gitlab::UrlBlocker::BlockedUrlError + rescue ::Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError errors.add(:links_to_spam, _('only supports valid HTTP(S) URLs')) end diff --git a/app/models/achievements/user_achievement.rb b/app/models/achievements/user_achievement.rb index 08ebadaa6b0..8b15b25c183 100644 --- a/app/models/achievements/user_achievement.rb +++ b/app/models/achievements/user_achievement.rb @@ -15,6 +15,23 @@ module Achievements optional: true scope :not_revoked, -> { where(revoked_by_user_id: nil) } + scope :order_by_priority_asc, -> { + keyset_order = Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'priority', + order_expression: ::Achievements::UserAchievement.arel_table[:priority].asc, + nullable: :nulls_last, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + order_expression: ::Achievements::UserAchievement.arel_table[:id].asc, + nullable: :not_nullable, + distinct: true + ) + ]) + reorder(keyset_order) + } scope :order_by_id_asc, -> { order(id: :asc) } def revoked? diff --git a/app/models/analytics/cycle_analytics/issue_stage_event.rb b/app/models/analytics/cycle_analytics/issue_stage_event.rb index 837eb35c839..1a8f1b7c84a 100644 --- a/app/models/analytics/cycle_analytics/issue_stage_event.rb +++ b/app/models/analytics/cycle_analytics/issue_stage_event.rb @@ -17,13 +17,44 @@ module Analytics where(condition.arel.exists) end - def self.issuable_id_column - :issue_id - end + class << self + def project_column + :project_id + end + + def issuable_id_column + :issue_id + end + + def issuable_model + ::Issue + end + + def select_columns + [ + *super, + issuable_model.arel_table[:weight], + issuable_model.arel_table[:sprint_id] + ] + end + + def column_list + [ + *super, + :weight, + :sprint_id + ] + end - def self.issuable_model - ::Issue + def insert_column_list + [ + *super, + :weight, + :sprint_id + ] + end end end end end +Analytics::CycleAnalytics::IssueStageEvent.prepend_mod_with('Analytics::CycleAnalytics::IssueStageEvent') diff --git a/app/models/analytics/cycle_analytics/merge_request_stage_event.rb b/app/models/analytics/cycle_analytics/merge_request_stage_event.rb index 0dfa322b2c3..7f85d284034 100644 --- a/app/models/analytics/cycle_analytics/merge_request_stage_event.rb +++ b/app/models/analytics/cycle_analytics/merge_request_stage_event.rb @@ -17,6 +17,10 @@ module Analytics where(condition.arel.exists) end + def self.project_column + :target_project_id + end + def self.issuable_id_column :merge_request_id end diff --git a/app/models/analytics/cycle_analytics/value_stream.rb b/app/models/analytics/cycle_analytics/value_stream.rb index 16446a5b463..7f8c6eef704 100644 --- a/app/models/analytics/cycle_analytics/value_stream.rb +++ b/app/models/analytics/cycle_analytics/value_stream.rb @@ -51,3 +51,4 @@ module Analytics end end end +Analytics::CycleAnalytics::ValueStream.prepend_mod_with('Analytics::CycleAnalytics::ValueStream') diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 7058bfd5650..15e44296635 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -6,7 +6,7 @@ class ApplicationRecord < ActiveRecord::Base include LegacyBulkInsert include CrossDatabaseModification include SensitiveSerializableHash - include ResetOnUnionError + include ResetOnColumnErrors self.abstract_class = true diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 153257636ba..824a2bd9fa4 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -16,12 +16,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord ignore_columns %i[instance_administration_project_id instance_administrators_group_id], remove_with: '16.2', remove_after: '2023-06-22' ignore_column :database_apdex_settings, remove_with: '16.4', remove_after: '2023-08-22' - ignore_columns %i[ - dashboard_notification_limit - dashboard_enforcement_limit - dashboard_limit_new_namespace_creation_enforcement_date - ], remove_with: '16.5', remove_after: '2023-08-22' - ignore_column %i[ relay_state_domain_allowlist in_product_marketing_emails_enabled @@ -36,7 +30,7 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord jitsu_project_xid jitsu_administrator_email ], remove_with: '16.5', remove_after: '2023-09-22' - ignore_columns %i[ai_access_token ai_access_token_iv], remove_with: '16.6', remove_after: '2023-10-22' + ignore_columns %i[encrypted_ai_access_token encrypted_ai_access_token_iv], remove_with: '16.6', remove_after: '2023-10-22' INSTANCE_REVIEW_MIN_USERS = 50 GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \ @@ -122,6 +116,10 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :default_branch_protection_defaults, json_schema: { filename: 'default_branch_protection_defaults' } validates :default_branch_protection_defaults, bytesize: { maximum: -> { DEFAULT_BRANCH_PROTECTIONS_DEFAULT_MAX_SIZE } } + validates :failed_login_attempts_unlock_period_in_minutes, + allow_nil: true, + numericality: { only_integer: true, greater_than: 0 } + validates :grafana_url, system_hook_url: ADDRESSABLE_URL_VALIDATION_OPTIONS.merge({ blocked_message: "is blocked: %{exception_message}. #{GRAFANA_URL_ERROR_MESSAGE}" @@ -269,6 +267,10 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :max_login_attempts, + allow_nil: true, + numericality: { only_integer: true, greater_than: 0 } + validates :max_pages_size, presence: true, numericality: { @@ -311,7 +313,7 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord if: :auto_devops_enabled? validates :enabled_git_access_protocol, - inclusion: { in: %w[ssh http], allow_blank: true } + inclusion: { in: ->(_) { enabled_git_access_protocol_values }, allow_blank: true } validates :domain_denylist, presence: { message: 'Domain denylist cannot be empty if denylist is enabled.' }, @@ -657,6 +659,7 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :throttle_authenticated_deprecated_api_period_in_seconds validates :throttle_protected_paths_requests_per_period validates :throttle_protected_paths_period_in_seconds + validates :project_jobs_api_rate_limit end with_options(numericality: { only_integer: true, greater_than_or_equal_to: 0 }) do @@ -805,11 +808,12 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord attr_encrypted :openai_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) attr_encrypted :anthropic_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) attr_encrypted :vertex_ai_credentials, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) + attr_encrypted :vertex_ai_access_token, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) # Restricting the validation to `on: :update` only to avoid cyclical dependencies with # License <--> ApplicationSetting. This method calls a license check when we create # ApplicationSetting from defaults which in turn depends on ApplicationSetting record. - # The currect default is defined in the `defaults` method so we don't need to validate + # The correct default is defined in the `defaults` method so we don't need to validate # it here. validates :disable_feed_token, inclusion: { in: [true, false], message: N_('must be a boolean value') }, on: :update @@ -834,6 +838,9 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord allow_nil: false, inclusion: { in: [true, false], message: N_('must be a boolean value') } + validates :math_rendering_limits_enabled, + 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 @@ -958,19 +965,31 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord false end + def max_login_attempts_column_exists? + self.class.database.cached_column_exists?(:max_login_attempts) + end + + def failed_login_attempts_unlock_period_in_minutes_column_exists? + self.class.database.cached_column_exists?(:failed_login_attempts_unlock_period_in_minutes) + end + private def self.human_attribute_name(attribute, *options) HUMANIZED_ATTRIBUTES[attribute.to_sym] || super end + def self.enabled_git_access_protocol_values + %w[ssh http] + end + def parsed_grafana_url @parsed_grafana_url ||= Gitlab::Utils.parse_url(grafana_url) end def parsed_kroki_url @parsed_kroki_url ||= Gitlab::UrlBlocker.validate!(kroki_url, schemes: %w[http https], enforce_sanitization: true)[0] - rescue Gitlab::UrlBlocker::BlockedUrlError => e + rescue Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError => e self.errors.add( :kroki_url, "is not valid. #{e}" diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 5a90e246499..1bd15a56de5 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -51,6 +51,7 @@ module ApplicationSettingImplementation container_registry_token_expire_delay: 5, container_registry_vendor: '', container_registry_version: '', + container_registry_db_enabled: false, custom_http_clone_url_root: nil, decompress_archive_file_timeout: 210, default_artifacts_expire_in: '30 days', @@ -87,6 +88,7 @@ module ApplicationSettingImplementation external_pipeline_validation_service_timeout: nil, external_pipeline_validation_service_token: nil, external_pipeline_validation_service_url: nil, + failed_login_attempts_unlock_period_in_minutes: nil, first_day_of_week: 0, floc_enabled: false, gitaly_timeout_default: 55, @@ -117,12 +119,14 @@ module ApplicationSettingImplementation login_recaptcha_protection_enabled: false, mailgun_signing_key: nil, mailgun_events_enabled: false, + math_rendering_limits_enabled: true, max_artifacts_size: Settings.artifacts['max_size'], max_attachment_size: Settings.gitlab['max_attachment_size'], + max_decompressed_archive_size: 25600, max_export_size: 0, max_import_size: 0, max_import_remote_file_size: 10240, - max_decompressed_archive_size: 25600, + max_login_attempts: nil, max_terraform_state_size_bytes: 0, max_yaml_size_bytes: 1.megabyte, max_yaml_depth: 100, @@ -267,7 +271,8 @@ module ApplicationSettingImplementation gitlab_dedicated_instance: false, ci_max_includes: 150, allow_account_deletion: true, - gitlab_shell_operation_limit: 600 + gitlab_shell_operation_limit: 600, + project_jobs_api_rate_limit: 600 }.tap do |hsh| hsh.merge!(non_production_defaults) unless Rails.env.production? end diff --git a/app/models/approval.rb b/app/models/approval.rb index ecc15077c8d..c3992994dd3 100644 --- a/app/models/approval.rb +++ b/app/models/approval.rb @@ -14,4 +14,7 @@ class Approval < ApplicationRecord validates :user_id, presence: true, uniqueness: { scope: [:merge_request_id] } scope :with_user, -> { joins(:user) } + scope :with_invalid_patch_id_sha, ->(patch_id_sha) do + where.not(patch_id_sha: patch_id_sha).or(where(patch_id_sha: nil)) + end end diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb index 73e3fa709b0..e445d08a096 100644 --- a/app/models/award_emoji.rb +++ b/app/models/award_emoji.rb @@ -9,7 +9,7 @@ class AwardEmoji < ApplicationRecord include Importable include IgnorableColumns - ignore_column :awardable_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' + ignore_column :awardable_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' belongs_to :awardable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :user diff --git a/app/models/badges/group_badge.rb b/app/models/badges/group_badge.rb index c0712f452df..f74c9f89e9f 100644 --- a/app/models/badges/group_badge.rb +++ b/app/models/badges/group_badge.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class GroupBadge < Badge + include EachBatch + belongs_to :group validates :group, presence: true diff --git a/app/models/bulk_import.rb b/app/models/bulk_import.rb index fde528e3fa0..a7ace7429d7 100644 --- a/app/models/bulk_import.rb +++ b/app/models/bulk_import.rb @@ -76,4 +76,8 @@ class BulkImport < ApplicationRecord def supports_batched_export? source_version_info >= self.class.min_gl_version_for_migration_in_batches end + + def completed? + finished? || failed? || timeout? + end end diff --git a/app/models/bulk_imports/tracker.rb b/app/models/bulk_imports/tracker.rb index d1a6f3b9a80..d9efd489af5 100644 --- a/app/models/bulk_imports/tracker.rb +++ b/app/models/bulk_imports/tracker.rb @@ -33,11 +33,9 @@ class BulkImports::Tracker < ApplicationRecord entity_scope.where(stage: next_stage_scope).with_status(:created) } - def self.stage_running?(entity_id, stage) - where(stage: stage, bulk_import_entity_id: entity_id) - .with_status(:created, :enqueued, :started) - .exists? - end + scope :running_trackers, -> (entity_id) { + where(bulk_import_entity_id: entity_id).with_status(:enqueued, :started) + } def pipeline_class unless entity.pipeline_exists?(pipeline_name) diff --git a/app/models/chat_name.rb b/app/models/chat_name.rb index d3fbfe3aa55..38e6273bf20 100644 --- a/app/models/chat_name.rb +++ b/app/models/chat_name.rb @@ -27,6 +27,6 @@ class ChatName < ApplicationRecord end def update_last_used_at? - last_used_at.nil? || last_used_at > LAST_USED_AT_INTERVAL.ago + last_used_at.nil? || last_used_at.before?(LAST_USED_AT_INTERVAL.ago) end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 2abb8e4be48..d2cf9058976 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -11,6 +11,7 @@ module Ci include Importable include Ci::HasRef include Ci::TrackEnvironmentUsage + include EachBatch extend ::Gitlab::Utils::Override @@ -414,7 +415,7 @@ module Ci end def options_scheduled_at - ChronicDuration.parse(options[:start_in], use_complete_matcher: true)&.seconds&.from_now + ChronicDuration.parse(options[:start_in])&.seconds&.from_now end def action? @@ -738,7 +739,7 @@ module Ci def artifacts_expire_in=(value) self.artifacts_expire_at = if value - ChronicDuration.parse(value, use_complete_matcher: true)&.seconds&.from_now + ChronicDuration.parse(value)&.seconds&.from_now end end @@ -1090,7 +1091,7 @@ module Ci end def has_expiring_artifacts? - artifacts_expire_at.present? && artifacts_expire_at > Time.current + artifacts_expire_at.present? && artifacts_expire_at.future? end def job_jwt_variables diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb index 4c723bb7c0c..555565ff621 100644 --- a/app/models/ci/build_metadata.rb +++ b/app/models/ci/build_metadata.rb @@ -14,7 +14,7 @@ module Ci self.table_name = 'p_ci_builds_metadata' self.primary_key = 'id' - partitionable scope: :build + partitionable scope: :build, partitioned: true belongs_to :build, class_name: 'CommitStatus' belongs_to :project diff --git a/app/models/ci/build_need.rb b/app/models/ci/build_need.rb index 00241908644..1831b7868f9 100644 --- a/app/models/ci/build_need.rb +++ b/app/models/ci/build_need.rb @@ -7,7 +7,7 @@ module Ci include SafelyChangeColumnDefault include BulkInsertSafe - MAX_JOB_NAME_LENGTH = 128 + MAX_JOB_NAME_LENGTH = 255 columns_changing_default :partition_id diff --git a/app/models/ci/catalog/components_project.rb b/app/models/ci/catalog/components_project.rb new file mode 100644 index 00000000000..2bc33a6f050 --- /dev/null +++ b/app/models/ci/catalog/components_project.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Ci + module Catalog + class ComponentsProject + # ComponentsProject is a type of Catalog Resource which contains one or more + # CI/CD components. + # It is responsible for retrieving the data of a component file, including the content, name, and file path. + + TEMPLATE_FILE = 'template.yml' + TEMPLATES_DIR = 'templates' + TEMPLATE_PATH_REGEX = '^templates\/\w+\-?\w+(?:\/template)?\.yml$' + + ComponentData = Struct.new(:content, :path, keyword_init: true) + + def initialize(project, sha = project&.default_branch) + @project = project + @sha = sha + end + + def fetch_component_paths(sha) + project.repository.search_files_by_regexp(TEMPLATE_PATH_REGEX, sha) + end + + def extract_component_name(path) + return unless path.match?(TEMPLATE_PATH_REGEX) + + dirname = File.dirname(path) + filename = File.basename(path, '.*') + + if dirname == TEMPLATES_DIR + filename + else + File.basename(dirname) + end + end + + def extract_inputs(blob) + result = Gitlab::Ci::Config::Yaml::Loader.new(blob).load_uninterpolated_yaml + + raise result.error_class, result.error unless result.valid? + + result.inputs + end + + def fetch_component(component_name) + path = simple_template_path(component_name) + content = fetch_content(path) + + if content.nil? + path = complex_template_path(component_name) + content = fetch_content(path) + end + + if content.nil? + path = legacy_template_path(component_name) + content = fetch_content(path) + end + + ComponentData.new(content: content, path: path) + end + + private + + attr_reader :project, :sha + + def fetch_content(component_path) + project.repository.blob_data_at(sha, component_path) + end + + # A simple template consists of a single file + def simple_template_path(component_name) + # TODO: Extract this line and move to fetch_content once we remove legacy fetching + return unless component_name.index('/').nil? + + File.join(TEMPLATES_DIR, "#{component_name}.yml") + end + + # A complex template is directory-based and may consist of multiple files. + # Given a path like "my-org/sub-group/the-project/templates/component" + # returns the entry point path: "templates/component/template.yml". + def complex_template_path(component_name) + # TODO: Extract this line and move to fetch_content once we remove legacy fetching + return unless component_name.index('/').nil? + + File.join(TEMPLATES_DIR, component_name, TEMPLATE_FILE) + end + + def legacy_template_path(component_name) + File.join(component_name, TEMPLATE_FILE).delete_prefix('/') + end + end + end +end diff --git a/app/models/ci/catalog/listing.rb b/app/models/ci/catalog/listing.rb index 1cb030c67c3..c3b18af8c3f 100644 --- a/app/models/ci/catalog/listing.rb +++ b/app/models/ci/catalog/listing.rb @@ -18,6 +18,8 @@ module Ci case sort.to_s when 'name_desc' then all_resources.order_by_name_desc when 'name_asc' then all_resources.order_by_name_asc + when 'latest_released_at_desc' then all_resources.order_by_latest_released_at_desc + when 'latest_released_at_asc' then all_resources.order_by_latest_released_at_asc else all_resources.order_by_created_at_desc end diff --git a/app/models/ci/catalog/resource.rb b/app/models/ci/catalog/resource.rb index 799cdce4af7..8ffc0292a69 100644 --- a/app/models/ci/catalog/resource.rb +++ b/app/models/ci/catalog/resource.rb @@ -18,6 +18,8 @@ module Ci scope :order_by_created_at_desc, -> { reorder(created_at: :desc) } scope :order_by_name_desc, -> { joins(:project).merge(Project.sorted_by_name_desc) } scope :order_by_name_asc, -> { joins(:project).merge(Project.sorted_by_name_asc) } + scope :order_by_latest_released_at_desc, -> { reorder(arel_table[:latest_released_at].desc.nulls_last) } + scope :order_by_latest_released_at_asc, -> { reorder(arel_table[:latest_released_at].asc.nulls_last) } delegate :avatar_path, :description, :name, :star_count, :forks_count, to: :project diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 3f9d8f07b06..2a346f97958 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -310,7 +310,7 @@ module Ci end def expiring? - expire_at.present? && expire_at > Time.current + expire_at.present? && expire_at.future? end def expire_in diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 5bf4e846304..0a876d26cc9 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -1366,6 +1366,11 @@ module Ci merge_request.merge_request_diff_for(merge_request_diff_sha) end + def reduced_build_attributes_list_for_rules? + ::Feature.enabled?(:reduced_build_attributes_list_for_rules, project) + end + strong_memoize_attr :reduced_build_attributes_list_for_rules? + private def add_message(severity, content) diff --git a/app/models/ci/ref.rb b/app/models/ci/ref.rb index 199e1cd07e7..8655e8eb9b8 100644 --- a/app/models/ci/ref.rb +++ b/app/models/ci/ref.rb @@ -36,7 +36,7 @@ module Ci next unless ci_ref.artifacts_locked? ci_ref.run_after_commit do - Ci::PipelineSuccessUnlockArtifactsWorker.perform_async(ci_ref.last_finished_pipeline_id) + Ci::Refs::UnlockPreviousPipelinesWorker.perform_async(ci_ref.id) end end end @@ -52,7 +52,11 @@ module Ci end def last_finished_pipeline_id - Ci::Pipeline.last_finished_for_ref_id(self.id)&.id + last_finished_pipeline&.id + end + + def last_finished_pipeline + Ci::Pipeline.last_finished_for_ref_id(self.id) end def artifacts_locked? diff --git a/app/models/ci/unlock_pipeline_request.rb b/app/models/ci/unlock_pipeline_request.rb new file mode 100644 index 00000000000..c8fc82f3e55 --- /dev/null +++ b/app/models/ci/unlock_pipeline_request.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Ci + class UnlockPipelineRequest + QUEUE_REDIS_KEY = 'ci_unlock_pipeline_requests:queue' + + def self.enqueue(pipeline_id) + unix_timestamp = Time.current.utc.to_i + pipeline_ids = Array(pipeline_id).uniq + pipeline_ids_with_scores = pipeline_ids.map do |id| + # The order of values per pair is `[score, key]`, so in this case, the unix timestamp is the score. + # By default, the sort order of sorted sets is from lowest to highest, though this does not matter much + # because we use `ZPOPMIN` to make sure to return the lowest/oldest request in terms of unix timestamp score. + [unix_timestamp, id] + end + + with_redis do |redis| + added = redis.zadd(QUEUE_REDIS_KEY, pipeline_ids_with_scores, nx: true) + log_event(:enqueued, pipeline_ids) if added > 0 + added + end + end + + def self.next! + with_redis do |redis| + pipeline_id, enqueue_timestamp = redis.zpopmin(QUEUE_REDIS_KEY) + break unless pipeline_id + + pipeline_id = pipeline_id.to_i + log_event(:picked_next, pipeline_id) + + [pipeline_id, enqueue_timestamp.to_i] + end + end + + def self.total_pending + with_redis do |redis| + redis.zcard(QUEUE_REDIS_KEY) + end + end + + def self.with_redis(&block) + Gitlab::Redis::SharedState.with(&block) + end + + def self.log_event(event, pipeline_id) + Gitlab::AppLogger.info( + message: "Pipeline unlock - #{event}", + pipeline_id: pipeline_id + ) + end + end +end diff --git a/app/models/clusters/agent_token.rb b/app/models/clusters/agent_token.rb index f4c497a42cc..e2754db73b9 100644 --- a/app/models/clusters/agent_token.rb +++ b/app/models/clusters/agent_token.rb @@ -33,6 +33,10 @@ module Clusters revoked: 1 } + def revoke! + update(status: :revoked) + end + def to_ability_name :cluster end diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index f9a34959675..5bd55fd6f4c 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -24,7 +24,6 @@ module Clusters has_one :cluster_project, -> { order(id: :desc) }, class_name: 'Clusters::Project' has_many :deployment_clusters has_many :deployments, inverse_of: :cluster, through: :deployment_clusters - has_many :successful_deployments, -> { success }, class_name: 'Deployment' has_many :environments, -> { distinct }, through: :deployments has_many :cluster_groups, class_name: 'Clusters::Group' diff --git a/app/models/clusters/concerns/prometheus_client.rb b/app/models/clusters/concerns/prometheus_client.rb index d2f69b813aa..b4234e9cc0a 100644 --- a/app/models/clusters/concerns/prometheus_client.rb +++ b/app/models/clusters/concerns/prometheus_client.rb @@ -35,7 +35,7 @@ module Clusters def configured? kube_client.present? && available? - rescue Gitlab::UrlBlocker::BlockedUrlError + rescue Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError false end diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index 5efbec45561..6ae0cd8e3fd 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -12,7 +12,7 @@ module Clusters REQUIRED_K8S_MIN_VERSION = 23 IGNORED_CONNECTION_EXCEPTIONS = [ - Gitlab::UrlBlocker::BlockedUrlError, + Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError, Kubeclient::HttpError, Errno::ECONNREFUSED, URI::InvalidURIError, diff --git a/app/models/commit_user_mention.rb b/app/models/commit_user_mention.rb index 9215e15f07d..fa7f065b6b4 100644 --- a/app/models/commit_user_mention.rb +++ b/app/models/commit_user_mention.rb @@ -3,7 +3,7 @@ class CommitUserMention < UserMention include IgnorableColumns - ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' + ignore_column :note_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' belongs_to :note end diff --git a/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb b/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb index d268c32c088..1d9cf5729cd 100644 --- a/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb +++ b/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb @@ -16,6 +16,7 @@ module Analytics scope :start_event_timestamp_before, -> (date) { where(arel_table[:start_event_timestamp].lteq(date)) } scope :authored, ->(user) { where(author_id: user) } scope :with_milestone_id, ->(milestone_id) { where(milestone_id: milestone_id) } + scope :without_milestone_id, -> (milestone_id) { where('milestone_id <> ? or milestone_id IS NULL', milestone_id) } scope :end_event_is_not_happened_yet, -> { where(end_event_timestamp: nil) } scope :order_by_end_event, -> (direction) do # ORDER BY end_event_timestamp, merge_request_id/issue_id, start_event_timestamp @@ -57,45 +58,19 @@ module Analytics class_methods do def upsert_data(data) - upsert_values = data.map do |row| - row.values_at( - :stage_event_hash_id, - :issuable_id, - :group_id, - :project_id, - :milestone_id, - :author_id, - :state_id, - :start_event_timestamp, - :end_event_timestamp - ) - end + upsert_values = data.map { |row| row.values_at(*column_list) } value_list = Arel::Nodes::ValuesList.new(upsert_values).to_sql query = <<~SQL INSERT INTO #{quoted_table_name} ( - stage_event_hash_id, - #{connection.quote_column_name(issuable_id_column)}, - group_id, - project_id, - milestone_id, - author_id, - state_id, - start_event_timestamp, - end_event_timestamp + #{insert_column_list.join(",\n")} ) #{value_list} ON CONFLICT(stage_event_hash_id, #{issuable_id_column}) DO UPDATE SET - group_id = excluded.group_id, - project_id = excluded.project_id, - milestone_id = excluded.milestone_id, - author_id = excluded.author_id, - state_id = excluded.state_id, - start_event_timestamp = excluded.start_event_timestamp, - end_event_timestamp = excluded.end_event_timestamp + #{column_updates.join(",\n")} SQL result = connection.execute(query) @@ -113,6 +88,51 @@ module Analytics def arel_order(arel_node, direction) direction.to_sym == :desc ? arel_node.desc : arel_node.asc end + + def select_columns + [ + issuable_model.arel_table[:id], + issuable_model.arel_table[project_column].as('project_id'), + issuable_model.arel_table[:milestone_id], + issuable_model.arel_table[:author_id], + issuable_model.arel_table[:state_id], + Project.arel_table[:parent_id].as('group_id') + ] + end + + def column_list + [ + :stage_event_hash_id, + :issuable_id, + :group_id, + :project_id, + :milestone_id, + :author_id, + :state_id, + :start_event_timestamp, + :end_event_timestamp + ] + end + + def insert_column_list + [ + :stage_event_hash_id, + connection.quote_column_name(issuable_id_column), + :group_id, + :project_id, + :milestone_id, + :author_id, + :state_id, + :start_event_timestamp, + :end_event_timestamp + ] + end + + def column_updates + insert_column_list.map do |column| + "#{column} = excluded.#{column}" + end + end end end end diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb index e830594af11..22e71c4fa13 100644 --- a/app/models/concerns/awardable.rb +++ b/app/models/concerns/awardable.rb @@ -13,26 +13,26 @@ module Awardable end class_methods do - def awarded(user, name = nil) + def awarded(user, name = nil, base_class_name = base_class.name, awardable_id_column = :id) award_emoji_table = Arel::Table.new('award_emoji') inner_query = award_emoji_table .project('true') .where(award_emoji_table[:user_id].eq(user.id)) - .where(award_emoji_table[:awardable_type].eq(base_class.name)) - .where(award_emoji_table[:awardable_id].eq(self.arel_table[:id])) + .where(award_emoji_table[:awardable_type].eq(base_class_name)) + .where(award_emoji_table[:awardable_id].eq(self.arel_table[awardable_id_column])) inner_query = inner_query.where(award_emoji_table[:name].eq(name)) if name.present? where(inner_query.exists) end - def not_awarded(user, name = nil) + def not_awarded(user, name = nil, base_class_name = base_class.name, awardable_id_column = :id) award_emoji_table = Arel::Table.new('award_emoji') inner_query = award_emoji_table .project('true') .where(award_emoji_table[:user_id].eq(user.id)) - .where(award_emoji_table[:awardable_type].eq(base_class.name)) - .where(award_emoji_table[:awardable_id].eq(self.arel_table[:id])) + .where(award_emoji_table[:awardable_type].eq(base_class_name)) + .where(award_emoji_table[:awardable_id].eq(self.arel_table[awardable_id_column])) inner_query = inner_query.where(award_emoji_table[:name].eq(name)) if name.present? @@ -52,14 +52,14 @@ module Awardable end # Order votes by emoji, optional sort order param `descending` defaults to true - def order_votes(emoji_name, direction) + def order_votes(emoji_name, direction, base_class_name = base_class.name, awardable_id_column = :id) awardable_table = self.arel_table awards_table = AwardEmoji.arel_table join_clause = awardable_table .join(awards_table, Arel::Nodes::OuterJoin) - .on(awards_table[:awardable_id].eq(awardable_table[:id]) - .and(awards_table[:awardable_type].eq(base_class.name).and(awards_table[:name].eq(emoji_name)))) + .on(awards_table[:awardable_id].eq(awardable_table[awardable_id_column]) + .and(awards_table[:awardable_type].eq(base_class_name).and(awards_table[:name].eq(emoji_name)))) .join_sources joins(join_clause).group(awardable_table[:id]).reorder( diff --git a/app/models/concerns/bulk_users_by_email_load.rb b/app/models/concerns/bulk_users_by_email_load.rb index edbd3e21458..55143ead30a 100644 --- a/app/models/concerns/bulk_users_by_email_load.rb +++ b/app/models/concerns/bulk_users_by_email_load.rb @@ -7,7 +7,7 @@ module BulkUsersByEmailLoad def users_by_emails(emails) Gitlab::SafeRequestLoader.execute(resource_key: user_by_email_resource_key, resource_ids: emails) do |emails| # have to consider all emails - even secondary, so use all_emails here - grouped_users_by_email = User.by_any_email(emails).preload(:emails).group_by(&:all_emails) + grouped_users_by_email = User.by_any_email(emails, confirmed: true).preload(:emails).group_by(&:all_emails) grouped_users_by_email.each_with_object({}) do |(found_emails, users), h| found_emails.each { |e| h[e] = users.first if emails.include?(e) } # don't include all emails for an account, only the ones we want diff --git a/app/models/concerns/chronic_duration_attribute.rb b/app/models/concerns/chronic_duration_attribute.rb index 7b7b61fdf06..44b34cf9b2f 100644 --- a/app/models/concerns/chronic_duration_attribute.rb +++ b/app/models/concerns/chronic_duration_attribute.rb @@ -18,7 +18,7 @@ module ChronicDurationAttribute begin new_value = if value.present? - ChronicDuration.parse(value, use_complete_matcher: true).to_i + ChronicDuration.parse(value).to_i else parameters[:default].presence end diff --git a/app/models/concerns/ci/deployable.rb b/app/models/concerns/ci/deployable.rb index d25151f9a34..844c8a1fa7d 100644 --- a/app/models/concerns/ci/deployable.rb +++ b/app/models/concerns/ci/deployable.rb @@ -4,6 +4,7 @@ module Ci module Deployable extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize included do prepend_mod_with('Ci::Deployable') # rubocop: disable Cop/InjectEnterpriseEditionModule @@ -17,8 +18,16 @@ module Ci end end + after_transition any => [:failed] do |job| + next unless job.stops_environment? + + job.run_after_commit do + Environments::StopJobFailedWorker.perform_async(id) + end + end + # Synchronize Deployment Status - # Please note that the data integirty is not assured because we can't use + # Please note that the data integrity is not assured because we can't use # a database transaction due to DB decomposition. after_transition do |job, transition| next if transition.loopback? @@ -32,13 +41,12 @@ module Ci end def outdated_deployment? - strong_memoize(:outdated_deployment) do - deployment_job? && - project.ci_forward_deployment_enabled? && - (!project.ci_forward_deployment_rollback_allowed? || incomplete?) && - deployment&.older_than_last_successful_deployment? - end + deployment_job? && + project.ci_forward_deployment_enabled? && + (!project.ci_forward_deployment_rollback_allowed? || incomplete?) && + deployment&.older_than_last_successful_deployment? end + strong_memoize_attr :outdated_deployment? # Virtual deployment status depending on the environment status. def deployment_status @@ -106,10 +114,10 @@ module Ci namespace = options.dig(:environment, :kubernetes, :namespace) - if namespace.present? # rubocop:disable Style/GuardClause - strong_memoize(:expanded_kubernetes_namespace) do - ExpandVariables.expand(namespace, -> { simple_variables }) - end + return unless namespace.present? + + strong_memoize(:expanded_kubernetes_namespace) do + ExpandVariables.expand(namespace, -> { simple_variables }) end end @@ -146,12 +154,11 @@ module Ci end def environment_status - strong_memoize(:environment_status) do - if has_environment_keyword? && merge_request - EnvironmentStatus.new(project, persisted_environment, merge_request, pipeline.sha) - end - end + return unless has_environment_keyword? && merge_request + + EnvironmentStatus.new(project, persisted_environment, merge_request, pipeline.sha) end + strong_memoize_attr :environment_status def on_stop options&.dig(:environment, :on_stop) diff --git a/app/models/concerns/enums/issuable_link.rb b/app/models/concerns/enums/issuable_link.rb new file mode 100644 index 00000000000..ca5728c2600 --- /dev/null +++ b/app/models/concerns/enums/issuable_link.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Enums + module IssuableLink + TYPE_RELATES_TO = 'relates_to' + TYPE_BLOCKS = 'blocks' + + def self.link_types + { TYPE_RELATES_TO => 0, TYPE_BLOCKS => 1 } + end + end +end diff --git a/app/models/concerns/import_state/sidekiq_job_tracker.rb b/app/models/concerns/import_state/sidekiq_job_tracker.rb index b7d0ed0f51b..9c892acb158 100644 --- a/app/models/concerns/import_state/sidekiq_job_tracker.rb +++ b/app/models/concerns/import_state/sidekiq_job_tracker.rb @@ -19,7 +19,7 @@ module ImportState end def self.jid_by(project_id:, status:) - select(:jid).where(status: status).find_by(project_id: project_id) + select(:id, :jid).where(status: status).find_by(project_id: project_id) end end end diff --git a/app/models/concerns/integrations/enable_ssl_verification.rb b/app/models/concerns/integrations/enable_ssl_verification.rb index 9735a9bf5f6..cb20955488a 100644 --- a/app/models/concerns/integrations/enable_ssl_verification.rb +++ b/app/models/concerns/integrations/enable_ssl_verification.rb @@ -5,7 +5,11 @@ module Integrations extend ActiveSupport::Concern prepended do - boolean_accessor :enable_ssl_verification + field :enable_ssl_verification, + type: :checkbox, + title: -> { s_('Integrations|SSL verification') }, + checkbox_label: -> { s_('Integrations|Enable SSL verification') }, + help: -> { s_('Integrations|Clear if using a self-signed certificate.') } end def initialize_properties @@ -17,18 +21,11 @@ module Integrations def fields super.tap do |fields| url_index = fields.index { |field| field[:name].ends_with?('_url') } - insert_index = url_index ? url_index + 1 : -1 + insert_index = url_index || -1 - fields.insert(insert_index, - Field.new( - name: 'enable_ssl_verification', - integration_class: self, - type: :checkbox, - title: s_('Integrations|SSL verification'), - checkbox_label: s_('Integrations|Enable SSL verification'), - help: s_('Integrations|Clear if using a self-signed certificate.') - ) - ) + enable_ssl_verification_index = fields.index { |field| field[:name] == 'enable_ssl_verification' } + + fields.insert(insert_index, fields.delete_at(enable_ssl_verification_index)) end end end diff --git a/app/models/concerns/issuable_link.rb b/app/models/concerns/issuable_link.rb index e884e5acecf..dcd2705185f 100644 --- a/app/models/concerns/issuable_link.rb +++ b/app/models/concerns/issuable_link.rb @@ -9,8 +9,8 @@ module IssuableLink extend ActiveSupport::Concern - TYPE_RELATES_TO = 'relates_to' - TYPE_BLOCKS = 'blocks' ## EE-only. Kept here to be used on link_type enum. + MAX_LINKS_COUNT = 100 + TYPE_RELATES_TO = Enums::IssuableLink::TYPE_RELATES_TO class_methods do def inverse_link_type(type) @@ -38,10 +38,11 @@ module IssuableLink validates :source, uniqueness: { scope: :target_id, message: 'is already related' } validate :check_self_relation validate :check_opposite_relation + validate :validate_max_number_of_links, on: :create scope :for_source_or_target, ->(issuable) { where(source: issuable).or(where(target: issuable)) } - enum link_type: { TYPE_RELATES_TO => 0, TYPE_BLOCKS => 1 } + enum link_type: Enums::IssuableLink.link_types private @@ -60,6 +61,27 @@ module IssuableLink errors.add(:source, "is already related to this #{self.class.issuable_name}") end end + + def validate_max_number_of_links + return unless source && target + + validate_max_number_of_links_for(source, :source) + validate_max_number_of_links_for(target, :target) + end + + def validate_max_number_of_links_for(item, attribute_name) + return unless item.linked_items_count >= MAX_LINKS_COUNT + + errors.add( + attribute_name, + format( + s_('This %{issuable} would exceed the maximum number of linked %{issuables} (%{limit}).'), + issuable: self.class.issuable_name, + issuables: self.class.issuable_name.pluralize, + limit: MAX_LINKS_COUNT + ) + ) + end end end diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index 06cee46645b..971089edc45 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -12,12 +12,12 @@ module Noteable class_methods do # `Noteable` class names that support replying to individual notes. def replyable_types - %w[Issue MergeRequest] + %w[Issue MergeRequest AbuseReport] end # `Noteable` class names that support resolvable notes. def resolvable_types - %w[Issue MergeRequest DesignManagement::Design] + %w[Issue MergeRequest DesignManagement::Design AbuseReport] end # `Noteable` class names that support creating/forwarding individual notes. diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb index f0bb1cc359b..a5994b538ce 100644 --- a/app/models/concerns/protected_ref_access.rb +++ b/app/models/concerns/protected_ref_access.rb @@ -71,6 +71,8 @@ module ProtectedRefAccess return false if current_user.nil? || no_access? return current_user.admin? if admin_access? + return false if Feature.enabled?(:check_membership_in_protected_ref_access) && !project.member?(current_user) + yield if block_given? user_can_access?(current_user) diff --git a/app/models/concerns/repository_storage_movable.rb b/app/models/concerns/repository_storage_movable.rb index 87ff413f2c1..77edabb9706 100644 --- a/app/models/concerns/repository_storage_movable.rb +++ b/app/models/concerns/repository_storage_movable.rb @@ -49,6 +49,7 @@ module RepositoryStorageMovable begin storage_move.container.set_repository_read_only!(skip_git_transfer_check: true) rescue StandardError => e + storage_move.do_fail! storage_move.add_error(e.message) next false end diff --git a/app/models/concerns/reset_on_column_errors.rb b/app/models/concerns/reset_on_column_errors.rb new file mode 100644 index 00000000000..8ace52ebff5 --- /dev/null +++ b/app/models/concerns/reset_on_column_errors.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module ResetOnColumnErrors + extend ActiveSupport::Concern + + MAX_RESET_PERIOD = 10.minutes + + included do |base| + base.rescue_from ActiveRecord::StatementInvalid, with: :reset_on_union_error + base.rescue_from ActiveModel::UnknownAttributeError, with: :reset_on_unknown_attribute_error + + base.class_attribute :previous_reset_columns_from_error + end + + class_methods do + def do_reset(exception) + class_to_be_reset = base_class + + class_to_be_reset.reset_column_information + Gitlab::ErrorTracking.log_exception(exception, { reset_model_name: class_to_be_reset.name }) + + class_to_be_reset.previous_reset_columns_from_error = Time.current + end + + def reset_on_union_error(exception) + if exception.message.include?("each UNION query must have the same number of columns") && should_reset? + do_reset(exception) + end + + raise + end + + def should_reset? + return false if base_class.previous_reset_columns_from_error? && + base_class.previous_reset_columns_from_error > MAX_RESET_PERIOD.ago + + Feature.enabled?(:reset_column_information_on_statement_invalid, type: :ops) + end + end + + def reset_on_union_error(exception) + self.class.reset_on_union_error(exception) + end + + def reset_on_unknown_attribute_error(exception) + self.class.do_reset(exception) if self.class.should_reset? + + raise + end +end diff --git a/app/models/concerns/reset_on_union_error.rb b/app/models/concerns/reset_on_union_error.rb deleted file mode 100644 index 42e350b0bed..00000000000 --- a/app/models/concerns/reset_on_union_error.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -module ResetOnUnionError - extend ActiveSupport::Concern - - MAX_RESET_PERIOD = 10.minutes - - included do |base| - base.rescue_from ActiveRecord::StatementInvalid, with: :reset_on_union_error - - base.class_attribute :previous_reset_columns_from_error - end - - class_methods do - def reset_on_union_error(exception) - if reset_on_statement_invalid?(exception) - class_to_be_reset = base_class - - class_to_be_reset.reset_column_information - Gitlab::ErrorTracking.log_exception(exception, { reset_model_name: class_to_be_reset.name }) - - class_to_be_reset.previous_reset_columns_from_error = Time.current - end - - raise - end - - def reset_on_statement_invalid?(exception) - return false unless exception.message.include?("each UNION query must have the same number of columns") - - return false if base_class.previous_reset_columns_from_error? && - base_class.previous_reset_columns_from_error > MAX_RESET_PERIOD.ago - - Feature.enabled?(:reset_column_information_on_statement_invalid, type: :ops) - end - end -end diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index ef14ff5fbe2..4c16ba18823 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -15,16 +15,7 @@ module Routable # # Returns a single object, or nil. - # rubocop:disable Metrics/CyclomaticComplexity - # rubocop:disable Metrics/PerceivedComplexity - def self.find_by_full_path( - path, - follow_redirects: false, - route_scope: Route, - redirect_route_scope: RedirectRoute, - optimize_routable: Routable.optimize_routable_enabled? - ) - + def self.find_by_full_path(path, follow_redirects: false, route_scope: nil) return unless path.present? # Convert path to string to prevent DB error: function lower(integer) does not exist @@ -35,49 +26,22 @@ module Routable # # We need to qualify the columns with the table name, to support both direct lookups on # Route/RedirectRoute, and scoped lookups through the Routable classes. - if optimize_routable - path_condition = { path: path } - - source_type_condition = if route_scope == Route - {} - else - { source_type: route_scope.klass.base_class } - end + path_condition = { path: path } - route = - Route.where(source_type_condition).find_by(path_condition) || - Route.where(source_type_condition).iwhere(path_condition).take + source_type_condition = route_scope ? { source_type: route_scope.klass.base_class } : {} - if follow_redirects - route ||= RedirectRoute.where(source_type_condition).iwhere(path_condition).take - end + route = + Route.where(source_type_condition).find_by(path_condition) || + Route.where(source_type_condition).iwhere(path_condition).take - return unless route - return route.source if route_scope == Route - - route_scope.find_by(id: route.source_id) - else - Gitlab::Database.allow_cross_joins_across_databases(url: - "https://gitlab.com/gitlab-org/gitlab/-/issues/420046") do - route = - route_scope.find_by(routes: { path: path }) || - route_scope.iwhere(Route.arel_table[:path] => path).take - - if follow_redirects - route ||= redirect_route_scope.iwhere(RedirectRoute.arel_table[:path] => path).take - end - - next unless route - - route.is_a?(Routable) ? route : route.source - end + if follow_redirects + route ||= RedirectRoute.where(source_type_condition).iwhere(path_condition).take end - end - # rubocop:enable Metrics/PerceivedComplexity - # rubocop:enable Metrics/CyclomaticComplexity - def self.optimize_routable_enabled? - Feature.enabled?(:optimize_routable) + return unless route + return route.source unless route_scope + + route_scope.find_by(id: route.source_id) end included do @@ -107,22 +71,12 @@ module Routable # # Returns a single object, or nil. def find_by_full_path(path, follow_redirects: false) - optimize_routable = Routable.optimize_routable_enabled? - - if optimize_routable - route_scope = all - redirect_route_scope = RedirectRoute - else - route_scope = includes(:route).references(:routes) - redirect_route_scope = joins(:redirect_routes) - end + route_scope = all Routable.find_by_full_path( path, follow_redirects: follow_redirects, - route_scope: route_scope, - redirect_route_scope: redirect_route_scope, - optimize_routable: optimize_routable + route_scope: route_scope ) end diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb deleted file mode 100644 index 5455a2159cd..00000000000 --- a/app/models/concerns/storage/legacy_namespace.rb +++ /dev/null @@ -1,112 +0,0 @@ -# frozen_string_literal: true - -module Storage - module LegacyNamespace - extend ActiveSupport::Concern - - include Gitlab::ShellAdapter - - def move_dir - proj_with_tags = first_project_with_container_registry_tags - - if proj_with_tags - raise Gitlab::UpdatePathError, "Namespace #{name} (#{id}) cannot be moved because at least one project (e.g. #{proj_with_tags.name} (#{proj_with_tags.id})) has tags in container registry" - end - - parent_was = if saved_change_to_parent? && parent_id_before_last_save.present? - Namespace.find(parent_id_before_last_save) # raise NotFound early if needed - end - - if saved_change_to_parent? - former_parent_full_path = parent_was&.full_path - parent_full_path = parent&.full_path - Gitlab::UploadsTransfer.new.move_namespace(path, former_parent_full_path, parent_full_path) - else - Gitlab::UploadsTransfer.new.rename_namespace(full_path_before_last_save, full_path) - end - - # If repositories moved successfully we need to - # send update instructions to users. - # However we cannot allow rollback since we moved namespace dir - # So we basically we mute exceptions in next actions - begin - send_update_instructions - write_projects_repository_config - rescue StandardError => e - Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e, - full_path_before_last_save: full_path_before_last_save, - full_path: full_path, - action: 'move_dir') - end - - true # false would cancel later callbacks but not rollback - end - - # Hooks - - # Save the storages before the projects are destroyed to use them on after destroy - def prepare_for_destroy - old_repository_storages - end - - private - - def move_repositories - # Move the namespace directory in all storages used by member projects - repository_storages(legacy_only: true).each do |repository_storage| - # Ensure old directory exists before moving it - Gitlab::GitalyClient::NamespaceService.allow do - gitlab_shell.add_namespace(repository_storage, full_path_before_last_save) - - # Ensure new directory exists before moving it (if there's a parent) - gitlab_shell.add_namespace(repository_storage, parent.full_path) if parent - - unless gitlab_shell.mv_namespace(repository_storage, full_path_before_last_save, full_path) - - Gitlab::AppLogger.error("Exception moving path #{repository_storage} from #{full_path_before_last_save} to #{full_path}") - - # if we cannot move namespace directory we should rollback - # db changes in order to prevent out of sync between db and fs - raise Gitlab::UpdatePathError, 'namespace directory cannot be moved' - end - end - end - end - - def old_repository_storages - @old_repository_storage_paths ||= repository_storages(legacy_only: true) - end - - def repository_storages(legacy_only: false) - # We need to get the storage paths for all the projects, even the ones that are - # pending delete. Unscoping also get rids of the default order, which causes - # problems with SELECT DISTINCT. - Project.unscoped do - namespace_projects = all_projects - namespace_projects = namespace_projects.without_storage_feature(:repository) if legacy_only - namespace_projects.pluck(Arel.sql('distinct(repository_storage)')) - end - end - - def rm_dir - # Remove the namespace directory in all storages paths used by member projects - old_repository_storages.each do |repository_storage| - # Move namespace directory into trash. - # We will remove it later async - new_path = "#{full_path}+#{id}+deleted" - - Gitlab::GitalyClient::NamespaceService.allow do - if gitlab_shell.mv_namespace(repository_storage, full_path, new_path) - Gitlab::AppLogger.info %(Namespace directory "#{full_path}" moved to "#{new_path}") - - # Remove namespace directory async with delay so - # GitLab has time to remove all projects first - run_after_commit do - GitlabShellWorker.perform_in(5.minutes, :rm_namespace, repository_storage, new_path) - end - end - end - end - end - end -end diff --git a/app/models/concerns/vulnerability_finding_helpers.rb b/app/models/concerns/vulnerability_finding_helpers.rb index e8a50497b20..94d091e8459 100644 --- a/app/models/concerns/vulnerability_finding_helpers.rb +++ b/app/models/concerns/vulnerability_finding_helpers.rb @@ -50,6 +50,7 @@ module VulnerabilityFindingHelpers finding_data = report_finding.to_hash.except( :compare_key, :identifiers, :location, :scanner, :links, :signatures, :flags, :evidence ) + identifiers = report_finding.identifiers.uniq(&:fingerprint).map do |identifier| Vulnerabilities::Identifier.new(identifier.to_hash.merge({ project: project })) end diff --git a/app/models/container_expiration_policy.rb b/app/models/container_expiration_policy.rb index f643fa7730b..a7ed5e28695 100644 --- a/app/models/container_expiration_policy.rb +++ b/app/models/container_expiration_policy.rb @@ -80,7 +80,7 @@ class ContainerExpirationPolicy < ApplicationRecord end def set_next_run_at - cadence_seconds = ChronicDuration.parse(cadence, use_complete_matcher: true).seconds + cadence_seconds = ChronicDuration.parse(cadence).seconds self.next_run_at = Time.zone.now + cadence_seconds end diff --git a/app/models/container_registry/protection.rb b/app/models/container_registry/protection.rb new file mode 100644 index 00000000000..33c94c0c893 --- /dev/null +++ b/app/models/container_registry/protection.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ContainerRegistry + module Protection + def self.table_name_prefix + 'container_registry_protection_' + end + end +end diff --git a/app/models/container_registry/protection/rule.rb b/app/models/container_registry/protection/rule.rb new file mode 100644 index 00000000000..a91f3633d75 --- /dev/null +++ b/app/models/container_registry/protection/rule.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module ContainerRegistry + module Protection + class Rule < ApplicationRecord + enum delete_protected_up_to_access_level: + Gitlab::Access.sym_options_with_owner.slice(:maintainer, :owner, :developer), + _prefix: :delete_protected_up_to + enum push_protected_up_to_access_level: + Gitlab::Access.sym_options_with_owner.slice(:maintainer, :owner, :developer), + _prefix: :push_protected_up_to + + belongs_to :project, inverse_of: :container_registry_protection_rules + + validates :container_path_pattern, presence: true, uniqueness: { scope: :project_id }, length: { maximum: 255 } + validates :delete_protected_up_to_access_level, presence: true + validates :push_protected_up_to_access_level, presence: true + end + end +end diff --git a/app/models/design_user_mention.rb b/app/models/design_user_mention.rb index 7d0cd72e9eb..ba1ef1b5712 100644 --- a/app/models/design_user_mention.rb +++ b/app/models/design_user_mention.rb @@ -3,7 +3,7 @@ class DesignUserMention < UserMention include IgnorableColumns - ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' + ignore_column :note_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' belongs_to :design, class_name: 'DesignManagement::Design' belongs_to :note diff --git a/app/models/discussion_note.rb b/app/models/discussion_note.rb index a1dfa0e72ec..fa830179022 100644 --- a/app/models/discussion_note.rb +++ b/app/models/discussion_note.rb @@ -9,7 +9,7 @@ class DiscussionNote < Note # Names of all implementers of `Noteable` that support discussions. def self.noteable_types - %w[MergeRequest Issue Commit Snippet] + %w[MergeRequest Issue Commit Snippet AbuseReport] end validates :noteable_type, inclusion: { in: noteable_types } diff --git a/app/models/environment.rb b/app/models/environment.rb index 29394c37e2c..efdcf7174aa 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -195,6 +195,10 @@ class Environment < ApplicationRecord transition %i[available stopping] => :stopped end + event :recover_stuck_stopping do + transition stopping: :available + end + state :available state :stopping state :stopped diff --git a/app/models/event.rb b/app/models/event.rb index 9e4a662aaa5..7de7ad8ccd6 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -12,7 +12,7 @@ class Event < ApplicationRecord include IgnorableColumns include EachBatch - ignore_column :target_id_convert_to_bigint, remove_with: '16.4', remove_after: '2023-09-22' + ignore_column :target_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' ACTIONS = HashWithIndifferentAccess.new( created: 1, diff --git a/app/models/group.rb b/app/models/group.rb index 9330ffef156..c83dd24e98e 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -423,15 +423,13 @@ class Group < Namespace owners.include?(user) end - def add_members(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil) + def add_members(users, access_level, current_user: nil, expires_at: nil) Members::Groups::CreatorService.add_members( # rubocop:disable CodeReuse/ServiceClass self, users, access_level, current_user: current_user, - expires_at: expires_at, - tasks_to_be_done: tasks_to_be_done, - tasks_project_id: tasks_project_id + expires_at: expires_at ) end @@ -512,9 +510,15 @@ class Group < Namespace members_with_parents(only_active_users: false) end - members_from_hiearchy.all_owners.left_outer_joins(:user) - .merge(User.without_project_bot) - .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417455") + owners = [] + + members_from_hiearchy.all_owners.non_invite.each_batch do |relation| + owners += relation.preload(:user).load.reject do |member| + member.user.project_bot? + end + end + + owners end def ldap_synced? @@ -657,12 +661,6 @@ class Group < Namespace .non_invite end - def users_with_parents - User - .where(id: members_with_parents.select(:user_id)) - .reorder(nil) - end - def users_with_descendants User .where(id: members_with_descendants.select(:user_id)) @@ -694,7 +692,7 @@ class Group < Namespace return GroupMember::NO_ACCESS unless user return GroupMember::OWNER if user.can_admin_all_resources? && !only_concrete_membership - max_member_access([user.id])[user.id] + max_member_access(user) end def mattermost_team_params @@ -879,10 +877,6 @@ class Group < Namespace ].compact.min end - def content_editor_on_issues_feature_flag_enabled? - feature_flag_enabled_for_self_or_ancestor?(:content_editor_on_issues) - end - def work_items_feature_flag_enabled? feature_flag_enabled_for_self_or_ancestor?(:work_items) end @@ -953,16 +947,16 @@ class Group < Namespace end end - def max_member_access(user_ids) - ::Gitlab::Database.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417455") do - Gitlab::SafeRequestLoader.execute( - resource_key: max_member_access_for_resource_key(User), - resource_ids: user_ids, - default_value: Gitlab::Access::NO_ACCESS - ) do |user_ids| - members_with_parents.where(user_id: user_ids).group(:user_id).maximum(:access_level) - end - end + def max_member_access(user) + Gitlab::SafeRequestLoader.execute( + resource_key: max_member_access_for_resource_key(User), + resource_ids: [user.id], + default_value: Gitlab::Access::NO_ACCESS + ) do |_| + next {} unless user.active? + + members_with_parents(only_active_users: false).where(user_id: user.id).group(:user_id).maximum(:access_level) + end.fetch(user.id) end def update_two_factor_requirement diff --git a/app/models/identity.rb b/app/models/identity.rb index a4c59694050..1a3a9a300b6 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -14,6 +14,7 @@ class Identity < MainClusterwide::ApplicationRecord after_destroy :clear_user_synced_attributes, if: :user_synced_attributes_metadata_from_provider? scope :for_user, ->(user) { where(user: user) } + scope :for_user_ids, ->(user_ids) { where(user_id: user_ids) } scope :with_provider, ->(provider) { where(provider: provider) } scope :with_extern_uid, ->(provider, extern_uid) do iwhere(extern_uid: normalize_uid(provider, extern_uid)).with_provider(provider) diff --git a/app/models/integration.rb b/app/models/integration.rb index d4c76f743a3..b4408301c6d 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -47,6 +47,9 @@ class Integration < ApplicationRecord Integrations::BaseThirdPartyWiki ].freeze + BASE_ATTRIBUTES = %w[id instance project_id group_id created_at updated_at + encrypted_properties encrypted_properties_iv properties].freeze + SECTION_TYPE_CONFIGURATION = 'configuration' SECTION_TYPE_CONNECTION = 'connection' SECTION_TYPE_TRIGGER = 'trigger' @@ -111,18 +114,18 @@ class Integration < ApplicationRecord validate :validate_belongs_to_project_or_group scope :external_issue_trackers, -> { where(category: 'issue_tracker').active } - # TODO: Will be modified in 15.0 - # Details: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74501#note_744393645 - scope :third_party_wikis, -> { where(type: %w[Integrations::Confluence Integrations::Shimo]).active } + scope :third_party_wikis, -> { where(category: 'third_party_wiki').active } scope :by_name, ->(name) { by_type(integration_name_to_type(name)) } scope :external_wikis, -> { by_name(:external_wiki).active } scope :active, -> { where(active: true) } scope :by_type, ->(type) { where(type: type) } # INTERNAL USE ONLY: use by_name instead - scope :by_active_flag, -> (flag) { where(active: flag) } - scope :inherit_from_id, -> (id) { where(inherit_from_id: id) } + scope :by_active_flag, ->(flag) { where(active: flag) } + scope :inherit_from_id, ->(id) { where(inherit_from_id: id) } scope :with_default_settings, -> { where.not(inherit_from_id: nil) } scope :with_custom_settings, -> { where(inherit_from_id: nil) } - scope :for_group, -> (group) { where(group_id: group, type: available_integration_types(include_project_specific: false)) } + scope :for_group, ->(group) { + where(group_id: group, type: available_integration_types(include_project_specific: false)) + } scope :for_instance, -> { where(instance: true, type: available_integration_types(include_project_specific: false)) } scope :push_hooks, -> { where(push_events: true, active: true) } @@ -216,13 +219,6 @@ class Integration < ApplicationRecord # Also keep track of updated properties in a similar way as ActiveModel::Dirty def self.boolean_accessor(*args) args.each do |arg| - # TODO: Allow legacy usage of `.boolean_accessor`, once all integrations - # are converted to the field DSL we can remove this and only call - # `.boolean_accessor` through `.field`. - # - # See https://gitlab.com/groups/gitlab-org/-/epics/7652 - prop_accessor(arg) unless method_defined?(arg) - class_eval <<~RUBY, __FILE__, __LINE__ + 1 # Make the original getter available as a private method. alias_method :#{arg}_before_type_cast, :#{arg} @@ -239,13 +235,14 @@ class Integration < ApplicationRecord RUBY end end + private_class_method :boolean_accessor def self.to_param raise NotImplementedError end def self.event_names - self.supported_events.map { |event| IntegrationsHelper.integration_event_field_name(event) } + supported_events.map { |event| IntegrationsHelper.integration_event_field_name(event) } end def self.supported_events @@ -406,7 +403,7 @@ class Integration < ApplicationRecord from_union([active.where(instance: true), active.where(group_id: group_ids, inherit_from_id: nil)]) .order(order) .group_by(&:type) - .count { |type, parents| build_from_integration(parents.first, association => owner.id).save } + .count { |_type, parents| build_from_integration(parents.first, association => owner.id).save } end def self.inherited_descendants_from_self_or_ancestors_from(integration) @@ -415,9 +412,10 @@ class Integration < ApplicationRecord .or(where(type: integration.type, instance: true)).select(:id) from_union([ - where(type: integration.type, inherit_from_id: inherit_from_ids, group: integration.group.descendants), - where(type: integration.type, inherit_from_id: inherit_from_ids, project: Project.in_namespace(integration.group.self_and_descendants)) - ]) + where(type: integration.type, inherit_from_id: inherit_from_ids, group: integration.group.descendants), + where(type: integration.type, inherit_from_id: inherit_from_ids, + project: Project.in_namespace(integration.group.self_and_descendants)) + ]) end def activated? @@ -490,10 +488,9 @@ class Integration < ApplicationRecord def to_database_hash column = self.class.attribute_aliases.fetch('type', 'type') - as_json( - except: %w[id instance project_id group_id created_at updated_at] - ).merge(column => type) - .merge(reencrypt_properties) + attributes_for_database.except(*BASE_ATTRIBUTES) + .merge(column => type) + .merge(reencrypt_properties) end def reencrypt_properties diff --git a/app/models/integrations/asana.rb b/app/models/integrations/asana.rb index 859522670ef..77555996cd9 100644 --- a/app/models/integrations/asana.rb +++ b/app/models/integrations/asana.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true -require 'asana' - module Integrations class Asana < Integration + TASK_URL_TEMPLATE = 'https://app.asana.com/api/1.0/tasks/%{task_gid}' + STORY_URL_TEMPLATE = 'https://app.asana.com/api/1.0/tasks/%{task_gid}/stories' + validates :api_key, presence: true, if: :activated? field :api_key, @@ -40,12 +41,6 @@ module Integrations %w[push] end - def client - @_client ||= ::Asana::Client.new do |c| - c.authentication :access_token, api_key - end - end - def execute(data) return unless supported_events.include?(data[:object_kind]) @@ -78,11 +73,12 @@ module Integrations taskid = tuple[2] || tuple[1] begin - task = ::Asana::Resources::Task.find_by_id(client, taskid) - task.add_comment(text: "#{push_msg} #{message}") + story_on_task_url = format(STORY_URL_TEMPLATE, task_gid: taskid) + Gitlab::HTTP.post(story_on_task_url, headers: { "Authorization" => "Bearer #{api_key}" }, body: { text: "#{push_msg} #{message}" }) if tuple[0] - task.update(completed: true) + task_url = format(TASK_URL_TEMPLATE, task_gid: taskid) + Gitlab::HTTP.put(task_url, headers: { "Authorization" => "Bearer #{api_key}" }, body: { completed: true }) end rescue StandardError => e log_error(e.message) diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb index 0b8432136dd..9f15532a0b0 100644 --- a/app/models/integrations/bamboo.rb +++ b/app/models/integrations/bamboo.rb @@ -28,14 +28,13 @@ module Integrations non_empty_password_title: -> { s_('ProjectService|Enter new password') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current password') } - validates :bamboo_url, presence: true, public_url: true, if: :activated? - validates :build_key, presence: true, if: :activated? - validates :username, - presence: true, - if: ->(service) { service.activated? && service.password } - validates :password, - presence: true, - if: ->(service) { service.activated? && service.username } + with_options if: :activated? do + validates :bamboo_url, presence: true, public_url: true + validates :build_key, presence: true + end + + validates :username, presence: true, if: ->(integration) { integration.activated? && integration.password } + validates :password, presence: true, if: ->(integration) { integration.activated? && integration.username } attr_accessor :response @@ -48,8 +47,16 @@ module Integrations end def help - docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bamboo'), target: '_blank', rel: 'noopener noreferrer' - s_('BambooService|Run CI/CD pipelines with Atlassian Bamboo. You must set up automatic revision labeling and a repository trigger in Bamboo. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + docs_link = ActionController::Base.helpers.link_to( + _('Learn more.'), + Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bamboo'), + target: '_blank', + rel: 'noopener noreferrer' + ) + format( + s_('BambooService|Run CI/CD pipelines with Atlassian Bamboo. You must set up automatic revision labeling and ' \ + 'a repository trigger in Bamboo. %{docs_link}').html_safe, + docs_link: docs_link.html_safe) end def self.to_param @@ -70,12 +77,18 @@ module Integrations get_path("updateAndBuild.action", { buildKey: build_key }) end - def calculate_reactive_cache(sha, ref) + def calculate_reactive_cache(sha, _ref) response = try_get_path("rest/api/latest/result/byChangeset/#{sha}") { build_page: read_build_page(response), commit_status: read_commit_status(response) } end + def avatar_url + ActionController::Base.helpers.image_path( + 'illustrations/third-party-logos/integrations-logos/atlassian-bamboo.svg' + ) + end + private def get_build_result(response) @@ -112,7 +125,7 @@ module Integrations if result.blank? 'Pending' else - result.dig('buildState') + result['buildState'] end return :error unless status.present? diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb index 2c929dc2cb3..b75801335bd 100644 --- a/app/models/integrations/base_chat_notification.rb +++ b/app/models/integrations/base_chat_notification.rb @@ -13,6 +13,8 @@ module Integrations tag_push pipeline wiki_page deployment incident ].freeze + GROUP_ONLY_SUPPORTED_EVENTS = %w[group_mention group_confidential_mention].freeze + SUPPORTED_EVENTS_FOR_LABEL_FILTER = %w[issue confidential_issue merge_request note confidential_note].freeze EVENT_CHANNEL = proc { |event| "#{event}_channel" } @@ -26,12 +28,12 @@ module Integrations attribute :category, default: 'chat' - prop_accessor :webhook, :username, :channel, :branches_to_be_notified, :labels_to_be_notified, :labels_to_be_notified_behavior + prop_accessor :webhook, :username, :channel, :branches_to_be_notified, :labels_to_be_notified, + :labels_to_be_notified_behavior, :notify_only_default_branch # Custom serialized properties initialization prop_accessor(*SUPPORTED_EVENTS.map { |event| EVENT_CHANNEL[event] }) - - boolean_accessor :notify_only_default_branch + prop_accessor(*GROUP_ONLY_SUPPORTED_EVENTS.map { |event| EVENT_CHANNEL[event] }) validates :webhook, presence: true, @@ -44,10 +46,10 @@ module Integrations super if properties.empty? - self.notify_only_broken_pipelines = true if self.respond_to?(:notify_only_broken_pipelines) + self.notify_only_broken_pipelines = true if respond_to?(:notify_only_broken_pipelines) self.branches_to_be_notified = "default" self.labels_to_be_notified_behavior = MATCH_ANY_LABEL - elsif !self.notify_only_default_branch.nil? + elsif !notify_only_default_branch.nil? # In older versions, there was only a boolean property named # `notify_only_default_branch`. Now we have a string property named # `branches_to_be_notified`. Instead of doing a background migration, we @@ -55,7 +57,7 @@ module Integrations # users haven't specified one already. When users edit the integration and # select a value for this new property, it will override everything. - self.branches_to_be_notified ||= notify_only_default_branch? ? "default" : "all" + self.branches_to_be_notified ||= notify_only_default_branch == 'true' ? "default" : "all" end end @@ -237,7 +239,7 @@ module Integrations case object_kind when "push", "tag_push" Integrations::ChatMessage::PushMessage.new(data) if notify_for_ref?(data) - when "issue" + when "issue", "incident" Integrations::ChatMessage::IssueMessage.new(data) unless update?(data) when "merge_request" Integrations::ChatMessage::MergeMessage.new(data) unless update?(data) @@ -249,8 +251,8 @@ module Integrations Integrations::ChatMessage::WikiPageMessage.new(data) when "deployment" Integrations::ChatMessage::DeploymentMessage.new(data) if notify_for_ref?(data) - when "incident" - Integrations::ChatMessage::IssueMessage.new(data) unless update?(data) + when "group_mention" + Integrations::ChatMessage::GroupMentionMessage.new(data) end end # rubocop:enable Metrics/CyclomaticComplexity diff --git a/app/models/integrations/base_slack_notification.rb b/app/models/integrations/base_slack_notification.rb index 65aec8b278f..09a0c9ba361 100644 --- a/app/models/integrations/base_slack_notification.rb +++ b/app/models/integrations/base_slack_notification.rb @@ -7,8 +7,6 @@ module Integrations ].freeze prop_accessor EVENT_CHANNEL['alert'] - prop_accessor EVENT_CHANNEL['group_mention'] - prop_accessor EVENT_CHANNEL['group_confidential_mention'] override :default_channel_placeholder def default_channel_placeholder @@ -18,7 +16,6 @@ module Integrations override :get_message def get_message(object_kind, data) return Integrations::ChatMessage::AlertMessage.new(data) if object_kind == 'alert' - return Integrations::ChatMessage::GroupMentionMessage.new(data) if object_kind == 'group_mention' super end diff --git a/app/models/integrations/chat_message/alert_message.rb b/app/models/integrations/chat_message/alert_message.rb index e2c689f9435..6c7ea9aed7c 100644 --- a/app/models/integrations/chat_message/alert_message.rb +++ b/app/models/integrations/chat_message/alert_message.rb @@ -34,12 +34,12 @@ module Integrations "Alert firing in #{strip_markup(project_name)}" end - private - def attachment_color "#C95823" end + private + def attachment_fields [ { diff --git a/app/models/integrations/chat_message/deployment_message.rb b/app/models/integrations/chat_message/deployment_message.rb index 0367459dfcb..4d3e962d885 100644 --- a/app/models/integrations/chat_message/deployment_message.rb +++ b/app/models/integrations/chat_message/deployment_message.rb @@ -30,7 +30,7 @@ module Integrations [{ text: format(description_message), - color: color + color: attachment_color }] end @@ -38,17 +38,7 @@ module Integrations {} end - private - - def message - if running? - "Starting deploy to #{strip_markup(environment)}" - else - "Deploy to #{strip_markup(environment)} #{humanized_status}" - end - end - - def color + def attachment_color case status when 'success' 'good' @@ -61,6 +51,16 @@ module Integrations end end + private + + def message + if running? + "Starting deploy to #{strip_markup(environment)}" + else + "Deploy to #{strip_markup(environment)} #{humanized_status}" + end + end + def project_link link(project_name, project_url) end diff --git a/app/models/integrations/chat_message/issue_message.rb b/app/models/integrations/chat_message/issue_message.rb index dd516362491..4c144bc2f68 100644 --- a/app/models/integrations/chat_message/issue_message.rb +++ b/app/models/integrations/chat_message/issue_message.rb @@ -41,6 +41,10 @@ module Integrations } end + def attachment_color + '#C95823' + end + private def message @@ -56,7 +60,7 @@ module Integrations title: issue_title, title_link: issue_url, text: format(SlackMarkdownSanitizer.sanitize_slack_link(description)), - color: '#C95823' + color: attachment_color }] end diff --git a/app/models/integrations/chat_message/pipeline_message.rb b/app/models/integrations/chat_message/pipeline_message.rb index f8a634be336..2abe4a6e9c7 100644 --- a/app/models/integrations/chat_message/pipeline_message.rb +++ b/app/models/integrations/chat_message/pipeline_message.rb @@ -89,6 +89,15 @@ module Integrations } end + def attachment_color + case status + when 'success' + detailed_status == 'passed with warnings' ? 'warning' : 'good' + else + 'danger' + end + end + private def actually_failed_jobs(builds) @@ -180,15 +189,6 @@ module Integrations end end - def attachment_color - case status - when 'success' - detailed_status == 'passed with warnings' ? 'warning' : 'good' - else - 'danger' - end - end - def ref_url if ref_type == 'tag' "#{project_url}/-/tags/#{ref}" diff --git a/app/models/integrations/chat_message/push_message.rb b/app/models/integrations/chat_message/push_message.rb index b17e28bb6c6..ee44fc98791 100644 --- a/app/models/integrations/chat_message/push_message.rb +++ b/app/models/integrations/chat_message/push_message.rb @@ -35,6 +35,10 @@ module Integrations } end + def attachment_color + '#345' + end + private def humanized_action(short: false) @@ -111,10 +115,6 @@ module Integrations ['pushed to', ref_link, "of #{project_link} (#{compare_link})"] end end - - def attachment_color - '#345' - end end end end diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb index 815e3669d78..33b2b52fa62 100644 --- a/app/models/integrations/discord.rb +++ b/app/models/integrations/discord.rb @@ -42,8 +42,15 @@ module Integrations s_('DiscordService|Override the default webhook (e.g. https://discord.com/api/webhooks/…)') end + override :supported_events + def supported_events + additional = group_level? ? %w[group_mention group_confidential_mention] : [] + + (self.class.supported_events + additional).freeze + end + def self.supported_events - %w[push issue confidential_issue merge_request note confidential_note tag_push pipeline wiki_page] + %w[push issue confidential_issue merge_request note confidential_note tag_push pipeline wiki_page deployment] end def configurable_channels? @@ -68,7 +75,7 @@ module Integrations builder.add_embed do |embed| embed.author = Discordrb::Webhooks::EmbedAuthor.new(name: message.user_name, icon_url: message.user_avatar) embed.description = (message.pretext + "\n" + Array.wrap(message.attachments).join("\n")).gsub(ATTACHMENT_REGEX, " \\k<entry> - \\k<name>\n") - embed.colour = 16543014 # The hex "fc6d26" as an Integer + embed.colour = embed_color(message) embed.timestamp = Time.now.utc end end @@ -77,6 +84,33 @@ module Integrations false end + COLOR_OVERRIDES = { + 'good' => '#0d532a', + 'warning' => '#703800', + 'danger' => '#8d1300' + }.freeze + + def embed_color(message) + return 'fc6d26'.hex unless message.respond_to?(:attachment_color) + + color = message.attachment_color + + color = COLOR_OVERRIDES[color] if COLOR_OVERRIDES.key?(color) + + color = color.delete_prefix('#') + + normalize_color(color).hex + end + + # Expands the short notation to the full colorcode notation + # 123456 -> 123456 + # 123 -> 112233 + def normalize_color(color) + return (color[0, 1] * 2) + (color[1, 1] * 2) + (color[2, 1] * 2) if color.length == 3 + + color + end + def custom_data(data) super(data).merge(markdown: true) end diff --git a/app/models/integrations/hangouts_chat.rb b/app/models/integrations/hangouts_chat.rb index 680752c3d56..6e4753470a3 100644 --- a/app/models/integrations/hangouts_chat.rb +++ b/app/models/integrations/hangouts_chat.rb @@ -30,12 +30,15 @@ module Integrations end def help - docs_link = ActionController::Base.helpers.link_to _('How do I set up a Google Chat webhook?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/hangouts_chat'), target: '_blank', rel: 'noopener noreferrer' - s_('Before enabling this integration, create a webhook for the room in Google Chat where you want to receive notifications from this project. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + docs_link = ActionController::Base.helpers.link_to(_('How do I set up a Google Chat webhook?'), + Rails.application.routes.url_helpers.help_page_url('user/project/integrations/hangouts_chat'), + target: '_blank', rel: 'noopener noreferrer') + format( + s_('Before enabling this integration, create a webhook for the room in Google Chat where you want to receive ' \ + 'notifications from this project. %{docs_link}').html_safe, docs_link: docs_link.html_safe) end - def default_channel_placeholder - end + def default_channel_placeholder; end def self.supported_events %w[push issue confidential_issue merge_request note confidential_note tag_push pipeline wiki_page] @@ -43,14 +46,20 @@ module Integrations private - def notify(message, opts) + def notify(message, _opts) url = webhook.dup key = parse_thread_key(message) url = Gitlab::Utils.add_url_parameters(url, { threadKey: key }) if key - simple_text = parse_simple_text_message(message) - ::HangoutsChat::Sender.new(url).simple(simple_text) + payload = { text: parse_simple_text_message(message) } + + Gitlab::HTTP.post( + url, + body: payload.to_json, + headers: { 'Content-Type' => 'application/json' }, + parse: nil + ).response end # Returns an appropriate key for threading messages in google chat diff --git a/app/models/integrations/integration_list.rb b/app/models/integrations/integration_list.rb new file mode 100644 index 00000000000..ab03e5c0e0a --- /dev/null +++ b/app/models/integrations/integration_list.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Integrations + class IntegrationList + def initialize(batch, integration_hash, association) + @batch = batch + @integration_hash = integration_hash + @association = association + end + + def to_array + [Integration, columns, values] + end + + private + + attr_reader :batch, :integration_hash, :association + + def columns + integration_hash.keys << "#{association}_id" + end + + def values + batch.select(:id).map do |record| + integration_hash.values << record.id + end + end + end +end diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb index d8d1f860e9a..f6e99454cb1 100644 --- a/app/models/integrations/jira.rb +++ b/app/models/integrations/jira.rb @@ -11,8 +11,12 @@ module Integrations PROJECTS_PER_PAGE = 50 JIRA_CLOUD_HOST = '.atlassian.net' - ATLASSIAN_REFERRER_GITLAB_COM = { atlOrigin: 'eyJpIjoiY2QyZTJiZDRkNGZhNGZlMWI3NzRkNTBmZmVlNzNiZTkiLCJwIjoianN3LWdpdGxhYi1pbnQifQ' }.freeze - ATLASSIAN_REFERRER_SELF_MANAGED = { atlOrigin: 'eyJpIjoiYjM0MTA4MzUyYTYxNDVkY2IwMzVjOGQ3ZWQ3NzMwM2QiLCJwIjoianN3LWdpdGxhYlNNLWludCJ9' }.freeze + ATLASSIAN_REFERRER_GITLAB_COM = { + atlOrigin: 'eyJpIjoiY2QyZTJiZDRkNGZhNGZlMWI3NzRkNTBmZmVlNzNiZTkiLCJwIjoianN3LWdpdGxhYi1pbnQifQ' + }.freeze + ATLASSIAN_REFERRER_SELF_MANAGED = { + atlOrigin: 'eyJpIjoiYjM0MTA4MzUyYTYxNDVkY2IwMzVjOGQ3ZWQ3NzMwM2QiLCJwIjoianN3LWdpdGxhYlNNLWludCJ9' + }.freeze API_ENDPOINTS = { find_issue: "/rest/api/2/issue/%s", @@ -28,11 +32,13 @@ module Integrations AUTH_TYPE_BASIC = 0 AUTH_TYPE_PAT = 1 - SNOWPLOW_EVENT_CATEGORY = self.name + SNOWPLOW_EVENT_CATEGORY = name validates :url, public_url: true, presence: true, if: :activated? validates :api_url, public_url: true, allow_blank: true - validates :username, presence: true, if: ->(object) { object.activated? && !object.personal_access_token_authorization? } + validates :username, presence: true, if: ->(object) { + object.activated? && !object.personal_access_token_authorization? + } validates :password, presence: true, if: :activated? validates :jira_auth_type, presence: true, inclusion: { in: [AUTH_TYPE_BASIC, AUTH_TYPE_PAT] }, if: :activated? validates :jira_issue_prefix, untrusted_regexp: true, length: { maximum: 255 }, if: :activated? @@ -130,7 +136,7 @@ module Integrations end # {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1 - def reference_pattern(only_long: true) + def reference_pattern(*) @reference_pattern ||= jira_issue_match_regex end @@ -144,7 +150,7 @@ module Integrations end def data_fields - jira_tracker_data || self.build_jira_tracker_data + jira_tracker_data || build_jira_tracker_data end def set_default_data @@ -186,8 +192,13 @@ module Integrations end def help - jira_doc_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('integration/jira/index') } - s_("JiraService|You must configure Jira before enabling this integration. %{jira_doc_link_start}Learn more.%{link_end}") % { jira_doc_link_start: jira_doc_link_start, link_end: '</a>'.html_safe } + jira_doc_link_start = format('<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe, + url: help_page_path('integration/jira/index')) + format( + s_("JiraService|You must configure Jira before enabling this integration. " \ + "%{jira_doc_link_start}Learn more.%{link_end}"), + jira_doc_link_start: jira_doc_link_start, + link_end: '</a>'.html_safe) end def title @@ -212,7 +223,8 @@ module Integrations { type: SECTION_TYPE_JIRA_TRIGGER, title: _('Trigger'), - description: s_('JiraService|When a Jira issue is mentioned in a commit or merge request, a remote link and comment (if enabled) will be created.') + description: s_('JiraService|When a Jira issue is mentioned in a commit or merge request, a remote link ' \ + 'and comment (if enabled) will be created.') }, { type: SECTION_TYPE_CONFIGURATION, @@ -313,7 +325,8 @@ module Integrations override :create_cross_reference_note def create_cross_reference_note(external_issue, mentioned_in, author) unless can_cross_reference?(mentioned_in) - return s_("JiraService|Events for %{noteable_model_name} are disabled.") % { noteable_model_name: mentioned_in.model_name.plural.humanize(capitalize: false) } + return format(s_("JiraService|Events for %{noteable_model_name} are disabled."), + noteable_model_name: mentioned_in.model_name.plural.humanize(capitalize: false)) end jira_issue = find_issue(external_issue.id) @@ -381,6 +394,10 @@ module Integrations jira_auth_type == AUTH_TYPE_PAT end + def avatar_url + ActionController::Base.helpers.image_path('illustrations/third-party-logos/integrations-logos/jira.svg') + end + private def jira_issue_match_regex @@ -398,10 +415,9 @@ module Integrations end def server_info - strong_memoize(:server_info) do - client_url.present? ? jira_request(API_ENDPOINTS[:server_info]) { client.ServerInfo.all.attrs } : nil - end + client_url.present? ? jira_request(API_ENDPOINTS[:server_info]) { client.ServerInfo.all.attrs } : nil end + strong_memoize_attr :server_info def can_cross_reference?(mentioned_in) case mentioned_in @@ -430,7 +446,8 @@ module Integrations true rescue StandardError => e path = API_ENDPOINTS[:transition_issue] % issue.id - log_exception(e, message: 'Issue transition failed', client_url: client_url, client_path: path, client_status: '400') + log_exception(e, message: 'Issue transition failed', client_url: client_url, client_path: path, + client_status: '400') false end @@ -488,9 +505,9 @@ module Integrations link_title = "#{entity_name.capitalize} - #{entity_title}" link_props = build_remote_link_props(url: entity_url, title: link_title) - unless comment_exists?(issue, message) - send_message(issue, message, link_props) - end + return if comment_exists?(issue, message) + + send_message(issue, message, link_props) end def comment_message(data) @@ -503,21 +520,22 @@ module Integrations project_link = build_jira_link(project.full_name, Gitlab::Routing.url_helpers.project_url(project)) branch = if entity[:branch].present? - s_('JiraService| on branch %{branch_link}') % { - branch_link: build_jira_link(entity[:branch], project_tree_url(project, entity[:branch])) - } + format(s_('JiraService| on branch %{branch_link}'), + branch_link: build_jira_link(entity[:branch], project_tree_url(project, entity[:branch]))) end entity_message = entity[:description].presence if all_details? entity_message ||= entity[:title].chomp - s_('JiraService|%{user_link} mentioned this issue in %{entity_link} of %{project_link}%{branch}:{quote}%{entity_message}{quote}') % { + format( + s_('JiraService|%{user_link} mentioned this issue in %{entity_link} of ' \ + '%{project_link}%{branch}:{quote}%{entity_message}{quote}'), user_link: user_link, entity_link: entity_link, project_link: project_link, branch: branch, entity_message: entity_message - } + ) end def build_jira_link(title, url) @@ -586,13 +604,13 @@ module Integrations end def resource_url(resource) - "#{Settings.gitlab.base_url.chomp("/")}#{resource}" + "#{Settings.gitlab.base_url.chomp('/')}#{resource}" end def build_entity_url(entity_type, entity_id) polymorphic_url( [ - self.project, + project, entity_type.to_sym ], id: entity_id, @@ -631,7 +649,8 @@ module Integrations yield rescue StandardError => e @error = e - log_exception(e, message: 'Error sending message', client_url: client_url, client_path: path, client_status: e.try(:code)) + log_exception(e, message: 'Error sending message', client_url: client_url, client_path: path, + client_status: e.try(:code)) nil end @@ -648,7 +667,8 @@ module Integrations results = server_info unless results.present? - Gitlab::AppLogger.warn(message: "Jira API returned no ServerInfo, setting deployment_type from URL", server_info: results, url: client_url) + Gitlab::AppLogger.warn(message: "Jira API returned no ServerInfo, setting deployment_type from URL", + server_info: results, url: client_url) return set_deployment_type_from_url end @@ -681,13 +701,25 @@ module Integrations end def jira_issues_section_description - jira_issues_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('integration/jira/issues') } - description = s_('JiraService|Work on Jira issues without leaving GitLab. Add a Jira menu to access a read-only list of your Jira issues. %{jira_issues_link_start}Learn more.%{link_end}') % { jira_issues_link_start: jira_issues_link_start, link_end: '</a>'.html_safe } + jira_issues_link_start = format('<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe, + url: help_page_path('integration/jira/issues')) + description = format( + s_('JiraService|Work on Jira issues without leaving GitLab. Add a Jira menu to access a read-only list of ' \ + 'your Jira issues. %{jira_issues_link_start}Learn more.%{link_end}'), + jira_issues_link_start: jira_issues_link_start, + link_end: '</a>'.html_safe + ) if project&.issues_enabled? - gitlab_issues_link_start = '<a href="%{url}">'.html_safe % { url: edit_project_path(project, anchor: 'js-shared-permissions') } + gitlab_issues_link_start = format('<a href="%{url}">'.html_safe, url: edit_project_path(project, + anchor: 'js-shared-permissions')) description += '<br><br>'.html_safe - description += s_("JiraService|Displaying Jira issues while leaving GitLab issues also enabled might be confusing. Consider %{gitlab_issues_link_start}disabling GitLab issues%{link_end} if they won't otherwise be used.") % { gitlab_issues_link_start: gitlab_issues_link_start, link_end: '</a>'.html_safe } + description += format( + s_("JiraService|Displaying Jira issues while leaving GitLab issues also enabled might be confusing. " \ + "Consider %{gitlab_issues_link_start}disabling GitLab issues%{link_end} if they won't otherwise be used."), + gitlab_issues_link_start: gitlab_issues_link_start, + link_end: '</a>'.html_safe + ) end description diff --git a/app/models/integrations/pipelines_email.rb b/app/models/integrations/pipelines_email.rb index fa22bd1a73c..01efbc3e4a4 100644 --- a/app/models/integrations/pipelines_email.rb +++ b/app/models/integrations/pipelines_email.rb @@ -37,8 +37,8 @@ module Integrations # `notify_only_default_branch`. Now we have a string property named # `branches_to_be_notified`. Instead of doing a background migration, we # opted to set a value for the new property based on the old one, if - # users hasn't specified one already. When users edit the service and - # selects a value for this new property, it will override everything. + # users haven't specified one already. When users edit the integration and + # select a value for this new property, it will override everything. self.branches_to_be_notified ||= notify_only_default_branch? ? "default" : "all" end diff --git a/app/models/integrations/pivotaltracker.rb b/app/models/integrations/pivotaltracker.rb index f42a872c49e..b3cbc988dd6 100644 --- a/app/models/integrations/pivotaltracker.rb +++ b/app/models/integrations/pivotaltracker.rb @@ -65,6 +65,10 @@ module Integrations end end + def avatar_url + ActionController::Base.helpers.image_path('illustrations/third-party-logos/integrations-logos/pivotal-tracker.svg') + end + private def allowed_branch?(ref) diff --git a/app/models/integrations/prometheus.rb b/app/models/integrations/prometheus.rb index 8474a5b7adf..ff8d07a1b4c 100644 --- a/app/models/integrations/prometheus.rb +++ b/app/models/integrations/prometheus.rb @@ -185,7 +185,7 @@ module Integrations # Remove in next required stop after %16.4 # https://gitlab.com/gitlab-org/gitlab/-/issues/338838 def sync_http_integration! - return unless manual_configuration_changed? + return unless manual_configuration_changed? && !manual_configuration_was.nil? project.alert_management_http_integrations .for_endpoint_identifier('legacy-prometheus') diff --git a/app/models/integrations/pushover.rb b/app/models/integrations/pushover.rb index e97c7e5e738..2feae29f627 100644 --- a/app/models/integrations/pushover.rb +++ b/app/models/integrations/pushover.rb @@ -125,5 +125,9 @@ module Integrations Gitlab::HTTP.post('/messages.json', base_uri: BASE_URI, body: pushover_data) end + + def avatar_url + ActionController::Base.helpers.image_path('illustrations/third-party-logos/integrations-logos/pushover.svg') + end end end diff --git a/app/models/integrations/telegram.rb b/app/models/integrations/telegram.rb index 7c196720386..71fe6f8d6ef 100644 --- a/app/models/integrations/telegram.rb +++ b/app/models/integrations/telegram.rb @@ -26,6 +26,12 @@ module Integrations section: SECTION_TYPE_CONFIGURATION, help: 'If selected, successful pipelines do not trigger a notification event.' + field :branches_to_be_notified, + type: :select, + section: SECTION_TYPE_CONFIGURATION, + title: -> { s_('Integrations|Branches for which notifications are to be sent') }, + choices: -> { branch_choices } + with_options if: :activated? do validates :token, :room, presence: true end @@ -60,6 +66,10 @@ module Integrations super - ['deployment'] end + def avatar_url + ActionController::Base.helpers.image_path('illustrations/third-party-logos/integrations-logos/telegram.svg') + end + private def set_webhook diff --git a/app/models/issue.rb b/app/models/issue.rb index 58383a6a329..b207785021d 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -543,7 +543,9 @@ class Issue < ApplicationRecord end end - def related_issues(current_user, preload: nil) + def related_issues(current_user = nil, authorize: true, preload: nil) + return [] if new_record? + related_issues = linked_issues_select .joins("INNER JOIN issue_links ON @@ -554,6 +556,7 @@ class Issue < ApplicationRecord .reorder('issue_link_id') related_issues = yield related_issues if block_given? + return related_issues unless authorize cross_project_filter = -> (issues) { issues.where(project: project) } Ability.issues_readable_by_user(related_issues, @@ -561,6 +564,10 @@ class Issue < ApplicationRecord filters: { read_cross_project: cross_project_filter }) end + def linked_items_count + related_issues(authorize: false).size + end + def can_be_worked_on? !self.closed? && !self.project.forked? end @@ -688,20 +695,14 @@ class Issue < ApplicationRecord # for performance reasons, check commit: 002ad215818450d2cbbc5fa065850a953dc7ada8 # Make sure to sync this method with issue_policy.rb def readable_by?(user) - if !project.issues_enabled? - false - elsif user.can_read_all_resources? - true - elsif project.personal? && project.team.owner?(user) + if user.can_read_all_resources? true - elsif confidential? && !assignee_or_author?(user) - project.member?(user, Gitlab::Access::REPORTER) elsif hidden? false - elsif project.public? || (project.internal? && !user.external?) - project.feature_available?(:issues, user) + elsif project + project_level_readable_by?(user) else - project.member?(user) + group_level_readable_by?(user) end end @@ -754,6 +755,31 @@ class Issue < ApplicationRecord private + def project_level_readable_by?(user) + if !project.issues_enabled? + false + elsif project.personal? && project.team.owner?(user) + true + elsif confidential? && !assignee_or_author?(user) + project.member?(user, Gitlab::Access::REPORTER) + elsif project.public? || (project.internal? && !user.external?) + project.feature_available?(:issues, user) + else + project.member?(user) + end + end + + def group_level_readable_by?(user) + # This should never happen as we don't support personal namespace level issues. Just additional safety. + return false unless namespace.is_a?(::Group) + + if confidential? && !assignee_or_author?(user) + namespace.member?(user, Gitlab::Access::REPORTER) + else + namespace.member?(user) + end + end + def due_date_after_start_date return unless start_date.present? && due_date.present? diff --git a/app/models/issue_user_mention.rb b/app/models/issue_user_mention.rb index ad0df0dca78..6c3bedfccca 100644 --- a/app/models/issue_user_mention.rb +++ b/app/models/issue_user_mention.rb @@ -5,5 +5,5 @@ class IssueUserMention < UserMention belongs_to :note include IgnorableColumns - ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' + ignore_column :note_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' end diff --git a/app/models/lfs_download_object.rb b/app/models/lfs_download_object.rb index 046e47262dd..ec190ebf5d8 100644 --- a/app/models/lfs_download_object.rb +++ b/app/models/lfs_download_object.rb @@ -19,6 +19,15 @@ class LfsDownloadObject @headers = headers || {} end + def to_hash + { + oid: oid, + size: size, + link: link, + headers: headers + }.stringify_keys + end + def sanitized_uri @sanitized_uri ||= Gitlab::UrlSanitizer.new(link) end diff --git a/app/models/loose_foreign_keys/deleted_record.rb b/app/models/loose_foreign_keys/deleted_record.rb index 1d26c3c11e4..6af80686ec2 100644 --- a/app/models/loose_foreign_keys/deleted_record.rb +++ b/app/models/loose_foreign_keys/deleted_record.rb @@ -36,34 +36,24 @@ class LooseForeignKeys::DeletedRecord < Gitlab::Database::SharedModel enum status: { pending: 1, processed: 2 }, _prefix: :status def self.load_batch_for_table(table, batch_size) - if Feature.enabled?("loose_foreign_keys_batch_load_using_union") - partition_names = Gitlab::Database::PostgresPartitionedTable.each_partition(table_name).map(&:name) - - unions = partition_names.map do |partition_name| - partition_number = partition_name[/\d+/].to_i - - select(arel_table[Arel.star], arel_table[:partition].as('partition_number')) - .from("#{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}.#{partition_name} AS #{table_name}") - .for_table(table) - .where(partition: partition_number) - .status_pending - .consume_order - .limit(batch_size) - end - - select(arel_table[Arel.star]) - .from_union(unions, remove_duplicates: false, remove_order: false) - .limit(batch_size) - .to_a - else - # selecting partition as partition_number to workaround the sliding partitioning column ignore + partition_names = Gitlab::Database::PostgresPartitionedTable.each_partition(table_name).map(&:name) + + unions = partition_names.map do |partition_name| + partition_number = partition_name[/\d+/].to_i + select(arel_table[Arel.star], arel_table[:partition].as('partition_number')) + .from("#{Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA}.#{partition_name} AS #{table_name}") .for_table(table) + .where(partition: partition_number) .status_pending .consume_order .limit(batch_size) - .to_a end + + select(arel_table[Arel.star]) + .from_union(unions, remove_duplicates: false, remove_order: false) + .limit(batch_size) + .to_a end def self.mark_records_processed(records) diff --git a/app/models/member.rb b/app/models/member.rb index cdf40eaa8f5..77e283044ea 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -29,10 +29,8 @@ class Member < ApplicationRecord belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :member_namespace, inverse_of: :namespace_members, foreign_key: 'member_namespace_id', class_name: 'Namespace' belongs_to :member_role - has_one :member_task delegate :name, :username, :email, :last_activity_on, to: :user, prefix: true - delegate :tasks_to_be_done, to: :member_task, allow_nil: true validates :expires_at, allow_blank: true, future_date: true validates :user, presence: true, unless: :invite? @@ -525,6 +523,7 @@ class Member < ApplicationRecord def validate_access_level_locked_for_member_role return unless member_role_id + return if member_role_changed? # it is ok to change the access level when changing member role if access_level_changed? errors.add(:access_level, _("cannot be changed since member is associated with a custom role")) @@ -577,12 +576,6 @@ class Member < ApplicationRecord def after_accept_invite post_create_hook - - run_after_commit_or_now do - if member_task - TasksToBeDone::CreateWorker.perform_async(member_task.id, created_by_id, [user_id.to_i]) - end - end end def after_decline_invite diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 52b9c3a80e3..b5a590d646e 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -62,9 +62,13 @@ class GroupMember < Member return false unless access_level == Gitlab::Access::OWNER return last_owner unless last_owner.nil? - group.member_owners_excluding_project_bots.where.not( - group: group, user_id: user_id - ).empty? + owners = group.member_owners_excluding_project_bots + + owners.reject! do |member| + member.group == group && member.user_id == user_id + end + + owners.empty? end private diff --git a/app/models/members/last_group_owner_assigner.rb b/app/models/members/last_group_owner_assigner.rb index 45cd8d8b000..707cd7bf31c 100644 --- a/app/models/members/last_group_owner_assigner.rb +++ b/app/models/members/last_group_owner_assigner.rb @@ -22,7 +22,7 @@ class LastGroupOwnerAssigner end def owner_ids - @owner_ids ||= owners.where(id: member_ids).ids + @owner_ids ||= member_ids & owners.map(&:id) end def member_ids @@ -30,6 +30,6 @@ class LastGroupOwnerAssigner end def owners - @owners ||= group.member_owners_excluding_project_bots.load + @owners ||= group.member_owners_excluding_project_bots end end diff --git a/app/models/members/member_task.rb b/app/models/members/member_task.rb deleted file mode 100644 index 6cf6b1adb45..00000000000 --- a/app/models/members/member_task.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -class MemberTask < ApplicationRecord - TASKS = { - code: 0, - ci: 1, - issues: 2 - }.freeze - - belongs_to :member - belongs_to :project - - validates :member, :project, presence: true - validates :tasks, inclusion: { in: TASKS.values } - validate :tasks_uniqueness - validate :project_in_member_source - - scope :for_members, -> (members) { joins(:member).where(member: members) } - - def tasks_to_be_done - Array(self[:tasks]).map { |task| TASKS.key(task) } - end - - def tasks_to_be_done=(tasks) - self[:tasks] = Array(tasks).map do |task| - TASKS[task.to_sym] - end.uniq - end - - private - - def tasks_uniqueness - errors.add(:tasks, 'are not unique') unless Array(tasks).length == Array(tasks).uniq.length - end - - def project_in_member_source - case member - when GroupMember - errors.add(:project, _('is not in the member group')) unless project.namespace == member.source - when ProjectMember - errors.add(:project, _('is not the member project')) unless project == member.source - end - end -end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 6a72ed6476e..d9726e76c4b 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -337,15 +337,19 @@ class MergeRequest < ApplicationRecord scope :by_squash_commit_sha, -> (sha) do where(squash_commit_sha: sha) end - scope :by_merge_or_squash_commit_sha, -> (sha) do - from_union([by_squash_commit_sha(sha), by_merge_commit_sha(sha)]) + scope :by_merged_commit_sha, -> (sha) do + where(merged_commit_sha: sha) + end + scope :by_merged_or_merge_or_squash_commit_sha, -> (sha) do + from_union([by_squash_commit_sha(sha), by_merge_commit_sha(sha), by_merged_commit_sha(sha)]) end scope :by_related_commit_sha, -> (sha) do from_union( [ by_commit_sha(sha), by_squash_commit_sha(sha), - by_merge_commit_sha(sha) + by_merge_commit_sha(sha), + by_merged_commit_sha(sha) ] ) end @@ -1231,19 +1235,23 @@ class MergeRequest < ApplicationRecord } end - def mergeable?(skip_ci_check: false, skip_discussions_check: false, skip_approved_check: false, check_mergeability_retry_lease: false, skip_rebase_check: false) + def mergeable?( + skip_ci_check: false, skip_discussions_check: false, skip_approved_check: false, check_mergeability_retry_lease: false, + skip_draft_check: false, skip_rebase_check: false, skip_blocked_check: false) + return false unless mergeable_state?( skip_ci_check: skip_ci_check, skip_discussions_check: skip_discussions_check, - skip_approved_check: skip_approved_check + skip_draft_check: skip_draft_check, + skip_approved_check: skip_approved_check, + skip_blocked_check: skip_blocked_check ) check_mergeability(sync_retry_lease: check_mergeability_retry_lease) - - can_be_merged? && (!should_be_rebased? || skip_rebase_check) + mergeable_git_state?(skip_rebase_check: skip_rebase_check) end - def mergeability_checks + def self.mergeable_state_checks # We want to have the cheapest checks first in the list, that way we can # fail fast before running the more expensive ones. # @@ -1256,17 +1264,52 @@ class MergeRequest < ApplicationRecord ] end - def mergeable_state?(skip_ci_check: false, skip_discussions_check: false, skip_approved_check: false) + def self.mergeable_git_state_checks + [ + ::MergeRequests::Mergeability::CheckConflictStatusService, + ::MergeRequests::Mergeability::CheckRebaseStatusService + ] + end + + def self.all_mergeability_checks + mergeable_state_checks + mergeable_git_state_checks + end + + def mergeable_state?( + skip_ci_check: false, skip_discussions_check: false, skip_approved_check: false, + skip_draft_check: false, skip_blocked_check: false) additional_checks = execute_merge_checks( + self.class.mergeable_state_checks, params: { skip_ci_check: skip_ci_check, skip_discussions_check: skip_discussions_check, - skip_approved_check: skip_approved_check + skip_approved_check: skip_approved_check, + skip_draft_check: skip_draft_check, + skip_blocked_check: skip_blocked_check } ) additional_checks.success? end + def mergeable_git_state?(skip_rebase_check: false) + checks = execute_merge_checks( + self.class.mergeable_git_state_checks, + params: { + skip_rebase_check: skip_rebase_check + } + ) + + checks.success? + end + + def all_mergeability_checks_results + execute_merge_checks( + self.class.all_mergeability_checks, + params: {}, + execute_all: true + ).payload[:results] + end + def ff_merge_possible? project.repository.ancestor?(target_branch_sha, diff_head_sha) end @@ -1689,7 +1732,7 @@ class MergeRequest < ApplicationRecord end def has_terraform_reports? - actual_head_pipeline&.complete_and_has_reports?(Ci::JobArtifact.of_report_type(:terraform)) + actual_head_pipeline&.has_reports?(Ci::JobArtifact.of_report_type(:terraform)) end def compare_accessibility_reports @@ -1957,15 +2000,11 @@ class MergeRequest < ApplicationRecord end def base_pipeline - @base_pipeline ||= project.ci_pipelines - .order(id: :desc) - .find_by(sha: diff_base_sha, ref: target_branch) + @base_pipeline ||= base_pipelines.last end def merge_base_pipeline - @merge_base_pipeline ||= project.ci_pipelines - .order(id: :desc) - .find_by(sha: actual_head_pipeline.target_sha, ref: target_branch) + @merge_base_pipeline ||= merge_base_pipelines.last end def discussions_rendered_on_frontend? @@ -2081,9 +2120,11 @@ class MergeRequest < ApplicationRecord false # Overridden in EE end - def execute_merge_checks(params: {}) + def execute_merge_checks(checks, params: {}, execute_all: false) # rubocop: disable CodeReuse/ServiceClass - MergeRequests::Mergeability::RunChecksService.new(merge_request: self, params: params).execute + MergeRequests::Mergeability::RunChecksService + .new(merge_request: self, params: params) + .execute(checks, execute_all: execute_all) # rubocop: enable CodeReuse/ServiceClass end @@ -2115,10 +2156,35 @@ class MergeRequest < ApplicationRecord !squash && target_project.squash_always? end + def current_patch_id_sha + return merge_request_diff.patch_id_sha if merge_request_diff.patch_id_sha.present? + + base_sha = diff_refs&.base_sha + head_sha = diff_refs&.head_sha + + return unless base_sha && head_sha + return if base_sha == head_sha + + project.repository.get_patch_id(base_sha, head_sha) + end + private attr_accessor :skip_fetch_ref + def merge_base_pipelines + target_branch_pipelines_for(sha: actual_head_pipeline.target_sha) + end + + def base_pipelines + target_branch_pipelines_for(sha: diff_base_sha) + end + + def target_branch_pipelines_for(sha:) + project.ci_pipelines + .where(sha: sha, ref: target_branch) + end + def set_draft_status self.draft = draft? end diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index bddc03d8b21..900f4bcfeb2 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -210,6 +210,8 @@ class MergeRequestDiff < ApplicationRecord # and save it to the database as serialized data def save_git_content ensure_commit_shas + set_patch_id_sha + save_commits save_diffs @@ -223,6 +225,16 @@ class MergeRequestDiff < ApplicationRecord keep_around_commits unless importing? end + def set_patch_id_sha + return unless base_commit_sha && head_commit_sha + return if base_commit_sha == head_commit_sha + + self.patch_id_sha = project.repository&.get_patch_id( + base_commit_sha, + head_commit_sha + ) + end + def set_as_latest_diff # Don't set merge_head diff as latest so it won't get considered as the # MergeRequest#merge_request_diff. diff --git a/app/models/merge_request_user_mention.rb b/app/models/merge_request_user_mention.rb index 3157f1ca2aa..548a91162cd 100644 --- a/app/models/merge_request_user_mention.rb +++ b/app/models/merge_request_user_mention.rb @@ -3,7 +3,7 @@ class MergeRequestUserMention < UserMention include IgnorableColumns - ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' + ignore_column :note_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' belongs_to :merge_request belongs_to :note diff --git a/app/models/milestone.rb b/app/models/milestone.rb index eb0da368c7b..d5b9a4dc30f 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -54,6 +54,7 @@ class Milestone < ApplicationRecord scope :order_by_name_asc, -> { order(Arel::Nodes::Ascending.new(arel_table[:title].lower)) } scope :reorder_by_due_date_asc, -> { reorder(arel_table[:due_date].asc.nulls_last) } scope :with_api_entity_associations, -> { preload(project: [:project_feature, :route, namespace: :route]) } + scope :preload_for_indexing, -> { includes(project: [:project_feature]) } scope :order_by_dates_and_title, -> { order(due_date: :asc, start_date: :asc, title: :asc) } validates :group, presence: true, unless: :project diff --git a/app/models/ml/model.rb b/app/models/ml/model.rb index fb15b9fea72..27f03ed5857 100644 --- a/app/models/ml/model.rb +++ b/app/models/ml/model.rb @@ -19,6 +19,11 @@ module Ml has_one :latest_version, -> { latest_by_model }, class_name: 'Ml::ModelVersion', inverse_of: :model scope :including_latest_version, -> { includes(:latest_version) } + scope :with_version_count, -> { + left_outer_joins(:versions) + .select("ml_models.*, count(ml_model_versions.id) as version_count") + .group(:id) + } scope :by_project, ->(project) { where(project_id: project.id) } def valid_default_experiment? @@ -32,5 +37,9 @@ module Ml create_with(default_experiment: experiment) .find_or_create_by(project: project, name: name) end + + def self.by_project_id_and_id(project_id, id) + find_by(project_id: project_id, id: id) + end end end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index ea0ea4de5b5..733b89fcaf2 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -6,7 +6,6 @@ class Namespace < ApplicationRecord include Gitlab::VisibilityLevel include Routable include AfterCommitQueue - include Storage::LegacyNamespace include Gitlab::SQL::Pattern include FeatureGate include FromUnion @@ -18,6 +17,9 @@ class Namespace < ApplicationRecord include Ci::NamespaceSettings include Referable include CrossDatabaseIgnoredTables + include IgnorableColumns + + ignore_column :unlock_membership_to_ldap, remove_with: '16.7', remove_after: '2023-11-16' cross_database_ignore_tables %w[routes redirect_routes], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424277' @@ -97,7 +99,10 @@ class Namespace < ApplicationRecord validates :path, presence: true, length: { maximum: URL_MAX_LENGTH } - validate :container_registry_namespace_path_validation + + validates :path, + format: { with: Gitlab::Regex.oci_repository_path_regex, message: Gitlab::Regex.oci_repository_path_regex_message }, + if: :path_changed? validates :path, namespace_path: true, if: ->(n) { !n.project_namespace? } # Project path validator is used for project namespaces for now to assure @@ -147,7 +152,6 @@ class Namespace < ApplicationRecord 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_save :reload_namespace_details @@ -155,8 +159,6 @@ class Namespace < ApplicationRecord after_sync_traversal_ids :schedule_sync_event_worker # custom callback defined in Namespaces::Traversal::Linear - # Legacy Storage specific hooks - 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? @@ -289,13 +291,6 @@ class Namespace < ApplicationRecord "#{self.class.reference_prefix}#{full_path}" end - def container_registry_namespace_path_validation - return if Feature.disabled?(:restrict_special_characters_in_namespace_path, self) - return if !path_changed? || path.match?(Gitlab::Regex.oci_repository_path_regex) - - errors.add(:path, Gitlab::Regex.oci_repository_path_regex_message) - end - def package_settings package_setting_relation || build_package_setting_relation end @@ -313,7 +308,7 @@ class Namespace < ApplicationRecord end def human_name - owner_name + owner_name || path end def any_project_has_container_registry_tags? diff --git a/app/models/namespace/detail.rb b/app/models/namespace/detail.rb index a65027733e9..f5e850830bc 100644 --- a/app/models/namespace/detail.rb +++ b/app/models/namespace/detail.rb @@ -1,13 +1,6 @@ # frozen_string_literal: true class Namespace::Detail < ApplicationRecord - include IgnorableColumns - - ignore_column :dashboard_notification_at, remove_with: '16.5', remove_after: '2023-08-22' - ignore_column :dashboard_enforcement_at, remove_with: '16.5', remove_after: '2023-08-22' - ignore_column :next_over_limit_check_at, remove_with: '16.5', remove_after: '2023-08-22' - ignore_column :free_user_cap_over_limit_notified_at, remove_with: '16.5', remove_after: '2023-08-22' - belongs_to :namespace, inverse_of: :namespace_details validates :namespace, presence: true validates :description, length: { maximum: 255 } diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb index 8d5d788c738..3befcdeaec5 100644 --- a/app/models/namespace_setting.rb +++ b/app/models/namespace_setting.rb @@ -10,7 +10,7 @@ class NamespaceSetting < ApplicationRecord belongs_to :namespace, inverse_of: :namespace_settings enum jobs_to_be_done: { basics: 0, move_repository: 1, code_storage: 2, exploring: 3, ci: 4, other: 5 }, _suffix: true - enum enabled_git_access_protocol: { all: 0, ssh: 1, http: 2 }, _suffix: true + enum enabled_git_access_protocol: { all: 0, ssh: 1, http: 2, ssh_certificates: 3 }, _suffix: true attribute :default_branch_protection_defaults, default: -> { {} } diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb index 1ca3c8e85f3..c3348c49ea1 100644 --- a/app/models/namespaces/traversal/linear.rb +++ b/app/models/namespaces/traversal/linear.rb @@ -61,8 +61,6 @@ module Namespaces # INPUT: [[4909902], [4909902,51065789], [4909902,51065793], [7135830], [15599674, 1], [15599674, 1, 3], [15599674, 2]] # RESULT: [[4909902], [7135830], [15599674, 1], [15599674, 2]] def shortest_traversal_ids_prefixes - raise ArgumentError, 'Feature not supported since the `:use_traversal_ids` is disabled' unless use_traversal_ids? - prefixes = [] # The array needs to be sorted (O(nlogn)) to ensure shortest elements are always first @@ -91,8 +89,6 @@ module Namespaces end def use_traversal_ids? - return false unless Feature.enabled?(:use_traversal_ids) - traversal_ids.present? end diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb index 6e79e3ac9a1..c63639e721a 100644 --- a/app/models/namespaces/traversal/linear_scopes.rb +++ b/app/models/namespaces/traversal/linear_scopes.rb @@ -12,14 +12,10 @@ module Namespaces # list of namespace IDs, it can be faster to reference the ID in # traversal_ids than the primary key ID column. def as_ids - return super unless use_traversal_ids? - select(Arel.sql('namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)]').as('id')) end def roots - return super unless use_traversal_ids? - root_ids = all.select("#{quoted_table_name}.traversal_ids[1]").distinct unscoped.where(id: root_ids) end @@ -37,20 +33,14 @@ module Namespaces end def self_and_descendants(include_self: true) - return super unless use_traversal_ids? - self_and_descendants_with_comparison_operators(include_self: include_self) end def self_and_descendant_ids(include_self: true) - return super unless use_traversal_ids? - self_and_descendants(include_self: include_self).as_ids end def self_and_hierarchy - return super unless use_traversal_ids_for_self_and_hierarchy_scopes? - unscoped.from_union([all.self_and_ancestors, all.self_and_descendants(include_self: false)]) end @@ -74,15 +64,6 @@ module Namespaces private - def use_traversal_ids? - Feature.enabled?(:use_traversal_ids) - end - - def use_traversal_ids_for_self_and_hierarchy_scopes? - Feature.enabled?(:use_traversal_ids_for_self_and_hierarchy_scopes) && - use_traversal_ids? - end - def self_and_ancestors_from_inner_join(include_self: true, upto: nil, hierarchy_order: nil) base_cte = all.reselect('namespaces.traversal_ids').as_cte(:base_ancestors_cte) diff --git a/app/models/note.rb b/app/models/note.rb index 8fc45436dc7..eae7a40fb4e 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -26,7 +26,7 @@ class Note < ApplicationRecord include IgnorableColumns include Spammable - ignore_column :id_convert_to_bigint, remove_with: '16.3', remove_after: '2023-08-22' + ignore_column :id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' ISSUE_TASK_SYSTEM_NOTE_PATTERN = /\A.*marked\sthe\stask.+as\s(completed|incomplete).*\z/ @@ -105,7 +105,7 @@ class Note < ApplicationRecord validates :note, presence: true validates :note, length: { maximum: Gitlab::Database::MAX_TEXT_SIZE_LIMIT } validates :project, presence: true, if: :for_project_noteable? - validates :namespace, presence: true + validates :namespace, presence: true, unless: :for_abuse_report? # Attachments are deprecated and are handled by Markdown uploader validates :attachment, file_size: { maximum: :max_attachment_size } @@ -383,7 +383,7 @@ class Note < ApplicationRecord end def for_project_noteable? - !for_personal_snippet? + !(for_personal_snippet? || for_abuse_report?) end def for_design? @@ -394,6 +394,10 @@ class Note < ApplicationRecord for_issue? || for_merge_request? end + def for_abuse_report? + noteable_type == AbuseReport.name + end + def skip_project_check? !for_project_noteable? end @@ -830,7 +834,11 @@ class Note < ApplicationRecord def ensure_namespace_id return if namespace_id.present? && !noteable_changed? && !project_changed? - self.namespace_id = if for_project_noteable? + self.namespace_id = if for_issue? + # Some issues are not project noteables (e.g. group-level work items) + # so we need this separate condition + noteable&.namespace_id + elsif for_project_noteable? project&.project_namespace_id elsif for_personal_snippet? noteable&.author&.namespace&.id diff --git a/app/models/note_diff_file.rb b/app/models/note_diff_file.rb index b0f6af0d853..624a722e369 100644 --- a/app/models/note_diff_file.rb +++ b/app/models/note_diff_file.rb @@ -4,7 +4,7 @@ class NoteDiffFile < ApplicationRecord include DiffFile include IgnorableColumns - ignore_column :diff_note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' + ignore_column :diff_note_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' scope :referencing_sha, -> (oids, project_id:) do joins(:diff_note).where(notes: { project_id: project_id, commit_id: oids }) diff --git a/app/models/packages/protection/rule.rb b/app/models/packages/protection/rule.rb index bb65be92b90..582b51475c2 100644 --- a/app/models/packages/protection/rule.rb +++ b/app/models/packages/protection/rule.rb @@ -4,18 +4,43 @@ module Packages module Protection class Rule < ApplicationRecord enum package_type: Packages::Package.package_types.slice(:npm) + enum push_protected_up_to_access_level: + Gitlab::Access.sym_options_with_owner.slice(:developer, :maintainer, :owner), + _prefix: :push_protected_up_to belongs_to :project, inverse_of: :package_protection_rules validates :package_name_pattern, presence: true, uniqueness: { scope: [:project_id, :package_type] }, length: { maximum: 255 } validates :package_type, presence: true - validates :push_protected_up_to_access_level, presence: true, - inclusion: { in: [ - Gitlab::Access::DEVELOPER, - Gitlab::Access::MAINTAINER, - Gitlab::Access::OWNER - ] } + validates :push_protected_up_to_access_level, presence: true + + before_save :set_package_name_pattern_ilike_query, if: :package_name_pattern_changed? + + scope :for_package_name, ->(package_name) { + return none if package_name.blank? + + where(":package_name ILIKE package_name_pattern_ilike_query", package_name: package_name) + } + + def self.push_protected_from?(access_level:, package_name:, package_type:) + return true if [access_level, package_name, package_type].any?(&:blank?) + + where(package_type: package_type, push_protected_up_to_access_level: access_level..) + .for_package_name(package_name) + .exists? + end + + private + + # We want to allow wildcard pattern (`*`) for the field `package_name_pattern` + # , e.g. `@my-scope/my-package-*`, etc. + # Therefore, we need to preprocess the field value before we can use the field in the ILIKE clause. + # E.g. convert wildcard character (`*`) to LIKE match character (`%`), escape certain characters, etc. + def set_package_name_pattern_ilike_query + self.package_name_pattern_ilike_query = self.class.sanitize_sql_like(package_name_pattern) + .tr('*', '%') + end end end end diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb index e8becc833ca..8a02415aef4 100644 --- a/app/models/pages/lookup_path.rb +++ b/app/models/pages/lookup_path.rb @@ -55,6 +55,12 @@ module Pages strong_memoize_attr :prefix def unique_host + # When serving custom domain we don't present the unique host to avoid + # GitLab Pages auto-redirect to the unique domain instead of keeping serving + # from the custom domain. + # https://gitlab.com/gitlab-org/gitlab/-/issues/426435 + return if domain.present? + url_builder.unique_host end strong_memoize_attr :unique_host diff --git a/app/models/pages_deployment.rb b/app/models/pages_deployment.rb index de7b2416258..f05ed2aac6e 100644 --- a/app/models/pages_deployment.rb +++ b/app/models/pages_deployment.rb @@ -6,8 +6,6 @@ class PagesDeployment < ApplicationRecord include FileStoreMounter include Gitlab::Utils::StrongMemoize - MIGRATED_FILE_NAME = "_migrated.zip" - attribute :file_store, :integer, default: -> { ::Pages::DeploymentUploader.default_store } belongs_to :project, optional: false @@ -16,11 +14,11 @@ class PagesDeployment < ApplicationRecord belongs_to :ci_build, class_name: 'Ci::Build', optional: true scope :older_than, ->(id) { where('id < ?', id) } - scope :migrated_from_legacy_storage, -> { where(file: MIGRATED_FILE_NAME) } scope :with_files_stored_locally, -> { where(file_store: ::ObjectStorage::Store::LOCAL) } scope :with_files_stored_remotely, -> { where(file_store: ::ObjectStorage::Store::REMOTE) } scope :project_id_in, ->(ids) { where(project_id: ids) } scope :active, -> { where(deleted_at: nil) } + scope :deactivated, -> { where('deleted_at < ?', Time.now.utc) } validates :file, presence: true validates :file_store, presence: true, inclusion: { in: ObjectStorage::SUPPORTED_STORES } @@ -43,10 +41,6 @@ class PagesDeployment < ApplicationRecord .update_all(updated_at: now, deleted_at: time || now) end - def migrated? - file.filename == MIGRATED_FILE_NAME - end - private def set_size diff --git a/app/models/plan_limits.rb b/app/models/plan_limits.rb index 245c0719439..478fc1c418a 100644 --- a/app/models/plan_limits.rb +++ b/app/models/plan_limits.rb @@ -7,7 +7,6 @@ class PlanLimits < ApplicationRecord ignore_column :ci_max_artifact_size_running_container_scanning, remove_with: '14.3', remove_after: '2021-08-22' ignore_column :web_hook_calls_high, remove_with: '15.10', remove_after: '2022-02-22' - ignore_column :ci_active_pipelines, remove_with: '16.3', remove_after: '2022-07-22' attribute :limits_history, :ind_jsonb, default: -> { {} } validates :limits_history, json_schema: { filename: 'plan_limits_history' } diff --git a/app/models/preloaders/group_root_ancestor_preloader.rb b/app/models/preloaders/group_root_ancestor_preloader.rb index 29c60e90964..410f48c8176 100644 --- a/app/models/preloaders/group_root_ancestor_preloader.rb +++ b/app/models/preloaders/group_root_ancestor_preloader.rb @@ -8,8 +8,6 @@ module Preloaders end def execute - return unless ::Feature.enabled?(:use_traversal_ids) - # type == 'Group' condition located on subquery to prevent a filter in the query root_query = Namespace.joins("INNER JOIN (#{join_sql}) as root_query ON root_query.root_id = namespaces.id") .select('namespaces.*, root_query.id as source_id') diff --git a/app/models/preloaders/project_root_ancestor_preloader.rb b/app/models/preloaders/project_root_ancestor_preloader.rb index ccb9d2eab98..1e96e139f94 100644 --- a/app/models/preloaders/project_root_ancestor_preloader.rb +++ b/app/models/preloaders/project_root_ancestor_preloader.rb @@ -10,7 +10,6 @@ module Preloaders def execute return unless @projects.is_a?(ActiveRecord::Relation) - return unless ::Feature.enabled?(:use_traversal_ids) root_query = Namespace.joins("INNER JOIN (#{join_sql}) as root_query ON root_query.root_id = namespaces.id") .select('namespaces.*, root_query.id as source_id') diff --git a/app/models/preloaders/user_max_access_level_in_groups_preloader.rb b/app/models/preloaders/user_max_access_level_in_groups_preloader.rb index 16d46facb96..aaa54e0228b 100644 --- a/app/models/preloaders/user_max_access_level_in_groups_preloader.rb +++ b/app/models/preloaders/user_max_access_level_in_groups_preloader.rb @@ -10,27 +10,11 @@ module Preloaders end def execute - if ::Feature.enabled?(:use_traversal_ids) - preload_with_traversal_ids - else - preload_direct_memberships - end + preload_with_traversal_ids end private - def preload_direct_memberships - group_memberships = GroupMember.active_without_invites_and_requests - .where(user: @user, source_id: @groups) - .group(:source_id) - .maximum(:access_level) - - @groups.each do |group| - access_level = group_memberships[group.id] - group.merge_value_to_request_store(User, @user.id, access_level) if access_level.present? - end - end - def preload_with_traversal_ids # Diagrammatic representation of this step: # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/111157#note_1271550140 diff --git a/app/models/project.rb b/app/models/project.rb index 5989584ce43..fd226d23e77 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -390,6 +390,7 @@ class Project < ApplicationRecord has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :project has_many :alert_management_http_integrations, class_name: 'AlertManagement::HttpIntegration', inverse_of: :project + has_many :container_registry_protection_rules, class_name: 'ContainerRegistry::Protection::Rule', inverse_of: :project # Container repositories need to remove data from the container registry, # which is not managed by the DB. Hence we're still using dependent: :destroy # here. @@ -559,13 +560,16 @@ class Project < ApplicationRecord allow_blank: true validates :name, presence: true, - length: { maximum: 255 }, - format: { with: Gitlab::Regex.project_name_regex, - message: Gitlab::Regex.project_name_regex_message } + length: { maximum: 255 } validates :path, presence: true, project_path: true, length: { maximum: 255 } + + validates :name, + format: { with: Gitlab::Regex.project_name_regex, + message: Gitlab::Regex.project_name_regex_message }, + if: :name_changed? validates :path, format: { with: Gitlab::Regex.oci_repository_path_regex, message: Gitlab::Regex.oci_repository_path_regex_message }, @@ -749,6 +753,7 @@ class Project < ApplicationRecord 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) } + scope :with_package_registry_enabled, -> { with_feature_enabled(:package_registry) } scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) } scope :with_merge_requests_available_for_user, ->(current_user) { with_feature_available_for_user(:merge_requests, current_user) } scope :with_issues_or_mrs_available_for_user, -> (user) do @@ -1449,7 +1454,7 @@ class Project < ApplicationRecord super(import_url.sanitized_url) credentials = import_url.credentials.to_h.transform_values { |value| CGI.unescape(value.to_s) } - create_or_update_import_data(credentials: credentials) + build_or_assign_import_data(credentials: credentials) else super(value) end @@ -1470,9 +1475,7 @@ class Project < ApplicationRecord valid?(:import_url) || errors.messages[:import_url].nil? end - # TODO: rename to build_or_assign_import_data as it doesn't save record - # https://gitlab.com/gitlab-org/gitlab/-/issues/377319 - def create_or_update_import_data(data: nil, credentials: nil) + def build_or_assign_import_data(data: nil, credentials: nil) return if data.nil? && credentials.nil? project_import_data = import_data || build_import_data @@ -2236,15 +2239,6 @@ class Project < ApplicationRecord pages_metadatum&.deployed? end - def pages_path - # TODO: when we migrate Pages to work with new storage types, change here to use disk_path - File.join(Settings.pages.path, full_path) - end - - def pages_available? - Gitlab.config.pages.enabled - end - def pages_show_onboarding? !(pages_metadatum&.onboarding_complete || pages_metadatum&.deployed) end @@ -2693,26 +2687,6 @@ class Project < ApplicationRecord self.merge_requests_ff_only_enabled || self.merge_requests_rebase_enabled end - def migrate_to_hashed_storage! - return unless storage_upgradable? - - if git_transfer_in_progress? - HashedStorage::ProjectMigrateWorker.perform_in(Gitlab::ReferenceCounter::REFERENCE_EXPIRE_TIME, id) - else - HashedStorage::ProjectMigrateWorker.perform_async(id) - end - end - - def rollback_to_legacy_storage! - return if legacy_storage? - - if git_transfer_in_progress? - HashedStorage::ProjectRollbackWorker.perform_in(Gitlab::ReferenceCounter::REFERENCE_EXPIRE_TIME, id) - else - HashedStorage::ProjectRollbackWorker.perform_async(id) - end - end - override :git_transfer_in_progress? def git_transfer_in_progress? GL_REPOSITORY_TYPES.any? do |type| @@ -3195,10 +3169,6 @@ class Project < ApplicationRecord creator.banned? && team.max_member_access(creator.id) == Gitlab::Access::OWNER end - def content_editor_on_issues_feature_flag_enabled? - group&.content_editor_on_issues_feature_flag_enabled? || Feature.enabled?(:content_editor_on_issues, self) - end - def work_items_feature_flag_enabled? group&.work_items_feature_flag_enabled? || Feature.enabled?(:work_items, self) end @@ -3346,7 +3316,7 @@ class Project < ApplicationRecord end def merge_requests_allowing_collaboration(source_branch = nil) - relation = source_of_merge_requests.opened.where(allow_collaboration: true) + relation = source_of_merge_requests.from_fork.opened.where(allow_collaboration: true) relation = relation.where(source_branch: source_branch) if source_branch relation end diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb index c328e7d37c8..4d0c6029235 100644 --- a/app/models/project_authorization.rb +++ b/app/models/project_authorization.rb @@ -11,6 +11,7 @@ class ProjectAuthorization < ApplicationRecord validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true validates :user, uniqueness: { scope: :project }, presence: true + scope :for_project, ->(projects) { where(project: projects) } scope :non_guests, -> { where('access_level > ?', ::Gitlab::Access::GUEST) } # TODO: To be removed after https://gitlab.com/gitlab-org/gitlab/-/issues/418205 diff --git a/app/models/project_import_data.rb b/app/models/project_import_data.rb index 7e0722ab68c..96c1ad7def8 100644 --- a/app/models/project_import_data.rb +++ b/app/models/project_import_data.rb @@ -5,6 +5,11 @@ require 'carrierwave/orm/activerecord' class ProjectImportData < ApplicationRecord prepend_mod_with('ProjectImportData') # rubocop: disable Cop/InjectEnterpriseEditionModule + # Timeout strategy can only be changed via API, currently only with GitHub and BitBucket Server + OPTIMISTIC_TIMEOUT = "optimistic" + PESSIMISTIC_TIMEOUT = "pessimistic" + TIMEOUT_STRATEGIES = [OPTIMISTIC_TIMEOUT, PESSIMISTIC_TIMEOUT].freeze + belongs_to :project, inverse_of: :import_data attr_encrypted :credentials, key: Settings.attr_encrypted_db_key_base, diff --git a/app/models/project_pages_metadatum.rb b/app/models/project_pages_metadatum.rb index 7a3ece4bc92..eca2e5a740e 100644 --- a/app/models/project_pages_metadatum.rb +++ b/app/models/project_pages_metadatum.rb @@ -12,6 +12,5 @@ class ProjectPagesMetadatum < ApplicationRecord belongs_to :pages_deployment scope :deployed, -> { where(deployed: true) } - scope :only_on_legacy_storage, -> { deployed.where(pages_deployment: nil) } scope :with_project_route_and_deployment, -> { preload(:pages_deployment, project: [:namespace, :route]) } end diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb index 69d1a9f4aeb..d16fe996672 100644 --- a/app/models/project_setting.rb +++ b/app/models/project_setting.rb @@ -21,6 +21,8 @@ class ProjectSetting < ApplicationRecord jitsu_administrator_email ], remove_with: '16.5', remove_after: '2023-09-22' + ignore_column :jitsu_key, remove_with: '16.7', remove_after: '2023-11-17' + attr_encrypted :cube_api_key, mode: :per_attribute_iv, key: Settings.attr_encrypted_db_key_base_32, diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 38521ae6090..586294f0dd0 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -43,15 +43,13 @@ class ProjectTeam member end - def add_members(users, access_level, current_user: nil, expires_at: nil, tasks_to_be_done: [], tasks_project_id: nil) + def add_members(users, access_level, current_user: nil, expires_at: nil) Members::Projects::CreatorService.add_members( # rubocop:disable CodeReuse/ServiceClass project, users, access_level, current_user: current_user, - expires_at: expires_at, - tasks_to_be_done: tasks_to_be_done, - tasks_project_id: tasks_project_id + expires_at: expires_at ) end diff --git a/app/models/repository.rb b/app/models/repository.rb index 1c27a7a64cf..e565de9c4ba 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -688,7 +688,7 @@ class Repository def head_tree(skip_flat_paths: true) return if empty? || root_ref.nil? - @head_tree ||= Tree.new(self, root_ref, nil, skip_flat_paths: skip_flat_paths) + @head_tree ||= Tree.new(self, root_ref, nil, skip_flat_paths: skip_flat_paths, ref_type: 'heads') end def tree(sha = :head, path = nil, recursive: false, skip_flat_paths: true, pagination_params: nil, ref_type: nil, rescue_not_found: true) @@ -1244,7 +1244,14 @@ class Repository def get_patch_id(old_revision, new_revision) raw_repository.get_patch_id(old_revision, new_revision) - rescue Gitlab::Git::CommandError + rescue Gitlab::Git::CommandError, Gitlab::Git::Repository::NoRepository => e + Gitlab::ErrorTracking.track_exception( + e, + project_id: project.id, + old_revision: old_revision, + new_revision: new_revision + ) + nil end @@ -1258,6 +1265,12 @@ class Repository Gitlab::Git::ObjectPool.init_from_gitaly(gitaly_object_pool, source_project&.repository) end + def get_file_attributes(revision, paths, attributes) + raw_repository + .get_file_attributes(revision, paths, attributes) + .map(&:to_h) + end + private def ancestor_cache_key(ancestor_id, descendant_id) diff --git a/app/models/resource_events/abuse_report_event.rb b/app/models/resource_events/abuse_report_event.rb index 59f88a63998..5881f87241d 100644 --- a/app/models/resource_events/abuse_report_event.rb +++ b/app/models/resource_events/abuse_report_event.rb @@ -16,7 +16,9 @@ module ResourceEvents close_report: 4, ban_user_and_close_report: 5, block_user_and_close_report: 6, - delete_user_and_close_report: 7 + delete_user_and_close_report: 7, + trust_user: 8, + trust_user_and_close_report: 9 } enum reason: { @@ -28,7 +30,8 @@ module ResourceEvents copyright: 6, malware: 7, other: 8, - unconfirmed: 9 + unconfirmed: 9, + trusted: 10 } def success_message diff --git a/app/models/service_desk/custom_email_credential.rb b/app/models/service_desk/custom_email_credential.rb index 8ccdd6f2261..5986ac8a43f 100644 --- a/app/models/service_desk/custom_email_credential.rb +++ b/app/models/service_desk/custom_email_credential.rb @@ -59,7 +59,7 @@ module ServiceDesk allow_localhost: false, allow_local_network: false ) - rescue Gitlab::UrlBlocker::BlockedUrlError => e + rescue Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError => e errors.add(:smtp_address, e) end end diff --git a/app/models/service_list.rb b/app/models/service_list.rb deleted file mode 100644 index 8a52539d128..00000000000 --- a/app/models/service_list.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -class ServiceList - def initialize(batch, service_hash, association) - @batch = batch - @service_hash = service_hash - @association = association - end - - def to_array - [Integration, columns, values] - end - - private - - attr_reader :batch, :service_hash, :association - - def columns - service_hash.keys << "#{association}_id" - end - - def values - batch.select(:id).map do |record| - service_hash.values << record.id - end - end -end diff --git a/app/models/snippet.rb b/app/models/snippet.rb index d4f8c1b3b0b..78b0c0849e3 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -79,6 +79,10 @@ class Snippet < ApplicationRecord scope :with_statistics, -> { joins(:statistics) } scope :inc_projects_namespace_route, -> { includes(project: [:route, :namespace]) } + scope :without_created_by_banned_user, -> do + where_not_exists(Users::BannedUser.where('snippets.author_id = banned_users.user_id')) + end + attr_mentionable :description participant :author @@ -365,6 +369,10 @@ class Snippet < ApplicationRecord def multiple_files? list_files.size > 1 end + + def hidden_due_to_author_ban? + Feature.enabled?(:hide_snippets_of_banned_users) && author.banned? + end end Snippet.prepend_mod_with('Snippet') diff --git a/app/models/snippet_user_mention.rb b/app/models/snippet_user_mention.rb index 8ef2c579a5a..2b6845495bc 100644 --- a/app/models/snippet_user_mention.rb +++ b/app/models/snippet_user_mention.rb @@ -3,7 +3,7 @@ class SnippetUserMention < UserMention include IgnorableColumns - ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' + ignore_column :note_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' belongs_to :snippet belongs_to :note diff --git a/app/models/ssh_host_key.rb b/app/models/ssh_host_key.rb index daa64f4e087..672a6d64127 100644 --- a/app/models/ssh_host_key.rb +++ b/app/models/ssh_host_key.rb @@ -157,7 +157,7 @@ class SshHostKey url.port = url.inferred_port [url, ip] - rescue Gitlab::UrlBlocker::BlockedUrlError + rescue Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError raise ArgumentError, "Invalid URL" end diff --git a/app/models/storage/hashed.rb b/app/models/storage/hashed.rb index 05e93f00912..5cef033e672 100644 --- a/app/models/storage/hashed.rb +++ b/app/models/storage/hashed.rb @@ -31,10 +31,6 @@ module Storage "#{base_dir}/#{disk_hash}" if disk_hash end - def rename_repo(old_full_path: nil, new_full_path: nil) - true - end - private # Generates the hash for the repository path and name on disk diff --git a/app/models/storage/legacy_project.rb b/app/models/storage/legacy_project.rb index 0d12a629b8e..700314e277a 100644 --- a/app/models/storage/legacy_project.rb +++ b/app/models/storage/legacy_project.rb @@ -23,27 +23,5 @@ module Storage def disk_path project.full_path end - - def rename_repo(old_full_path: nil, new_full_path: nil) - old_full_path ||= project.full_path_before_last_save - new_full_path ||= project.build_full_path - - if gitlab_shell.mv_repository(repository_storage, old_full_path, new_full_path) - # If repository moved successfully we need to send update instructions to users. - # However we cannot allow rollback since we moved repository - # So we basically we mute exceptions in next actions - begin - gitlab_shell.mv_repository(repository_storage, "#{old_full_path}.wiki", "#{new_full_path}.wiki") - return true - rescue StandardError => e - Gitlab::AppLogger.error("Exception renaming #{old_full_path} -> #{new_full_path}: #{e}") - # Returning false does not rollback after_* transaction but gives - # us information about failing some of tasks - return false - end - end - - false - end end end diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb index 58a154b8986..c4178d3c5f1 100644 --- a/app/models/suggestion.rb +++ b/app/models/suggestion.rb @@ -5,7 +5,7 @@ class Suggestion < ApplicationRecord include Suggestible include IgnorableColumns - ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' + ignore_column :note_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' belongs_to :note, inverse_of: :suggestions validates :note, presence: true, unless: :importing? diff --git a/app/models/system/broadcast_message.rb b/app/models/system/broadcast_message.rb index 332baea4449..06f0115ade6 100644 --- a/app/models/system/broadcast_message.rb +++ b/app/models/system/broadcast_message.rb @@ -125,7 +125,7 @@ module System end def future? - starts_at > Time.current + starts_at.future? end def now_or_future? diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 4e71a13a3a1..dc93decce5e 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -4,7 +4,7 @@ class SystemNoteMetadata < ApplicationRecord include Importable include IgnorableColumns - ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' + ignore_column :note_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' # These notes's action text might contain a reference that is external. # We should always force a deep validation upon references that are found diff --git a/app/models/timelog.rb b/app/models/timelog.rb index eb72456b435..b6b4decc64b 100644 --- a/app/models/timelog.rb +++ b/app/models/timelog.rb @@ -5,7 +5,7 @@ class Timelog < ApplicationRecord include IgnorableColumns include Sortable - ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' + ignore_column :note_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' before_save :set_project diff --git a/app/models/todo.rb b/app/models/todo.rb index d159b51a0eb..e64dbf83a4c 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -6,7 +6,7 @@ class Todo < ApplicationRecord include EachBatch include IgnorableColumns - ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22' + ignore_column :note_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' # Time to wait for todos being removed when not visible for user anymore. # Prevents TODOs being removed by mistake, for example, removing access from a user @@ -25,6 +25,7 @@ class Todo < ApplicationRecord REVIEW_REQUESTED = 9 MEMBER_ACCESS_REQUESTED = 10 REVIEW_SUBMITTED = 11 # This is an EE-only feature + OKR_CHECKIN_REQUESTED = 12 # This is an EE-only feature ACTION_NAMES = { ASSIGNED => :assigned, @@ -37,7 +38,8 @@ class Todo < ApplicationRecord DIRECTLY_ADDRESSED => :directly_addressed, MERGE_TRAIN_REMOVED => :merge_train_removed, MEMBER_ACCESS_REQUESTED => :member_access_requested, - REVIEW_SUBMITTED => :review_submitted + REVIEW_SUBMITTED => :review_submitted, + OKR_CHECKIN_REQUESTED => :okr_checkin_requested }.freeze ACTIONS_MULTIPLE_ALLOWED = [Todo::MENTIONED, Todo::DIRECTLY_ADDRESSED, Todo::MEMBER_ACCESS_REQUESTED].freeze @@ -78,6 +80,7 @@ class Todo < ApplicationRecord scope :for_type, -> (type) { where(target_type: type) } scope :for_target, -> (id) { where(target_id: id) } scope :for_commit, -> (id) { where(commit_id: id) } + scope :not_in_users, -> (user_ids) { where.not('todos.user_id' => user_ids) } scope :with_entity_associations, -> do preload(:target, :author, :note, group: :route, project: [:route, :group, { namespace: [:route, :owner] }, :project_setting]) end diff --git a/app/models/tree.rb b/app/models/tree.rb index 4d62334800d..030e7d9e85f 100644 --- a/app/models/tree.rb +++ b/app/models/tree.rb @@ -13,10 +13,10 @@ class Tree @repository = repository @sha = sha @path = path - @ref_type = ExtractsRef.ref_type(ref_type) + @ref_type = ExtractsRef::RefExtractor.ref_type(ref_type) git_repo = @repository.raw_repository - ref = ExtractsRef.qualify_ref(@sha, ref_type) + ref = ExtractsRef::RefExtractor.qualify_ref(@sha, ref_type) @entries, @cursor = Gitlab::Git::Tree.where(git_repo, ref, @path, recursive, skip_flat_paths, rescue_not_found, pagination_params) diff --git a/app/models/upload.rb b/app/models/upload.rb index a4fbc703146..59ce9a1f37a 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -2,6 +2,7 @@ class Upload < ApplicationRecord include Checksummable + include EachBatch # Upper limit for foreground checksum processing CHECKSUM_THRESHOLD = 100.megabytes diff --git a/app/models/user.rb b/app/models/user.rb index c4e867ab571..4034677509f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -271,6 +271,7 @@ class User < MainClusterwide::ApplicationRecord has_many :bulk_imports has_many :custom_attributes, class_name: 'UserCustomAttribute' + has_one :trusted_with_spam_attribute, -> { UserCustomAttribute.trusted_with_spam }, class_name: 'UserCustomAttribute' has_many :callouts, class_name: 'Users::Callout' has_many :group_callouts, class_name: 'Users::GroupCallout' has_many :project_callouts, class_name: 'Users::ProjectCallout' @@ -306,6 +307,7 @@ class User < MainClusterwide::ApplicationRecord has_many :awarded_user_achievements, class_name: 'Achievements::UserAchievement', foreign_key: 'awarded_by_user_id', inverse_of: :awarded_by_user has_many :revoked_user_achievements, class_name: 'Achievements::UserAchievement', foreign_key: 'revoked_by_user_id', inverse_of: :revoked_by_user has_many :achievements, through: :user_achievements, class_name: 'Achievements::Achievement', inverse_of: :users + has_many :vscode_settings, class_name: 'VsCode::Settings::VsCodeSetting', inverse_of: :user # # Validations @@ -1234,10 +1236,6 @@ class User < MainClusterwide::ApplicationRecord authorized_projects(Gitlab::Access::REPORTER).non_archived.with_issues_enabled end - def preloaded_member_roles_for_projects(projects) - # overridden in EE - end - # rubocop: disable CodeReuse/ServiceClass def require_ssh_key? count = Users::KeysCountService.new(self).count @@ -2226,8 +2224,8 @@ class User < MainClusterwide::ApplicationRecord } end - def allow_possible_spam? - custom_attributes.by_key(UserCustomAttribute::ALLOW_POSSIBLE_SPAM).exists? + def trusted? + trusted_with_spam_attribute.present? end def namespace_commit_email_for_namespace(namespace) @@ -2511,14 +2509,6 @@ class User < MainClusterwide::ApplicationRecord def ci_namespace_mirrors_for_group_members(level) search_members = group_members.where('access_level >= ?', level) - # This reduces searched prefixes to only shortest ones - # to avoid querying descendants since they are already covered - # by ancestor namespaces. If the FF is not available fallback to - # inefficient search: https://gitlab.com/gitlab-org/gitlab/-/issues/336436 - unless Feature.enabled?(:use_traversal_ids) - return Ci::NamespaceMirror.contains_any_of_namespaces(search_members.pluck(:source_id)) - end - traversal_ids = Group.joins(:all_group_members) .merge(search_members) .shortest_traversal_ids_prefixes diff --git a/app/models/user_custom_attribute.rb b/app/models/user_custom_attribute.rb index 15d50071bf6..728c1f4844a 100644 --- a/app/models/user_custom_attribute.rb +++ b/app/models/user_custom_attribute.rb @@ -10,13 +10,15 @@ class UserCustomAttribute < ApplicationRecord scope :by_user_id, ->(user_id) { where(user_id: user_id) } scope :by_updated_at, ->(updated_at) { where(updated_at: updated_at) } scope :arkose_sessions, -> { by_key('arkose_session') } + scope :trusted_with_spam, -> { by_key(TRUSTED_BY) } BLOCKED_BY = 'blocked_by' UNBLOCKED_BY = 'unblocked_by' ARKOSE_RISK_BAND = 'arkose_risk_band' AUTO_BANNED_BY_ABUSE_REPORT_ID = 'auto_banned_by_abuse_report_id' AUTO_BANNED_BY_SPAM_LOG_ID = 'auto_banned_by_spam_log_id' - ALLOW_POSSIBLE_SPAM = 'allow_possible_spam' + TRUSTED_BY = 'trusted_by' + AUTO_BANNED_BY = 'auto_banned_by' IDENTITY_VERIFICATION_PHONE_EXEMPT = 'identity_verification_phone_exempt' class << self @@ -50,6 +52,17 @@ class UserCustomAttribute < ApplicationRecord return unless spam_log custom_attribute = { user_id: spam_log.user_id, key: AUTO_BANNED_BY_SPAM_LOG_ID, value: spam_log.id } + upsert_custom_attributes([custom_attribute]) + end + + def set_trusted_by(user:, trusted_by:) + return unless user && trusted_by + + custom_attribute = { + user_id: user.id, + key: UserCustomAttribute::TRUSTED_BY, + value: "#{trusted_by.username}/#{trusted_by.id}+#{Time.current}" + } upsert_custom_attributes([custom_attribute]) end diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb index def0765560e..60dd89c3ee7 100644 --- a/app/models/users/callout.rb +++ b/app/models/users/callout.rb @@ -73,9 +73,10 @@ module Users new_navigation_callout: 71, # 72 removed in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/129022 namespace_over_storage_users_combined_alert: 73, # EE-only - rich_text_editor: 74, + # 74 removed in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/132751 vsd_feedback_banner: 75, # EE-only - security_policy_protected_branch_modification: 76 # EE-only + security_policy_protected_branch_modification: 76, # EE-only + vulnerability_report_grouping: 77 # EE-only } validates :feature_name, diff --git a/app/models/users/credit_card_validation.rb b/app/models/users/credit_card_validation.rb index 086943884a5..276d549006f 100644 --- a/app/models/users/credit_card_validation.rb +++ b/app/models/users/credit_card_validation.rb @@ -23,18 +23,18 @@ module Users scope :find_or_initialize_by_user, ->(user_id) { where(user_id: user_id).first_or_initialize } scope :by_banned_user, -> { joins(:banned_user) } - scope :similar_by_holder_name, ->(holder_name) do - if holder_name.present? - where('lower(holder_name) = lower(:value)', value: holder_name) + scope :similar_by_holder_name, ->(holder_name_hash) do + if holder_name_hash.present? + where(holder_name_hash: holder_name_hash) else none end end scope :similar_to, ->(credit_card_validation) do where( - expiration_date: credit_card_validation.expiration_date, - last_digits: credit_card_validation.last_digits, - network: credit_card_validation.network + expiration_date_hash: credit_card_validation.expiration_date_hash, + last_digits_hash: credit_card_validation.last_digits_hash, + network_hash: credit_card_validation.network_hash ) end @@ -48,11 +48,11 @@ module Users end def similar_holder_names_count - self.class.similar_by_holder_name(holder_name).count + self.class.similar_by_holder_name(holder_name_hash).count end def used_by_banned_user? - self.class.by_banned_user.similar_to(self).similar_by_holder_name(holder_name).exists? + self.class.by_banned_user.similar_to(self).similar_by_holder_name(holder_name_hash).exists? end def set_last_digits_hash diff --git a/app/models/users/in_product_marketing_email.rb b/app/models/users/in_product_marketing_email.rb index f220cfd17c5..5b9255f93b1 100644 --- a/app/models/users/in_product_marketing_email.rb +++ b/app/models/users/in_product_marketing_email.rb @@ -3,30 +3,21 @@ module Users class InProductMarketingEmail < ApplicationRecord include BulkInsertSafe + include IgnorableColumns - BUILD_IOS_APP_GUIDE = 'build_ios_app_guide' - CAMPAIGNS = [BUILD_IOS_APP_GUIDE].freeze + ignore_column :campaign, remove_with: '16.7', remove_after: '2023-11-15' belongs_to :user validates :user, presence: true - - validates :track, :series, presence: true, if: -> { campaign.blank? } - validates :campaign, presence: true, if: -> { track.blank? && series.blank? } - validates :campaign, inclusion: { in: CAMPAIGNS }, allow_nil: true + validates :track, presence: true + validates :series, presence: true validates :user_id, uniqueness: { scope: [:track, :series], message: 'track series email has already been sent' }, if: -> { track.present? } - validates :user_id, uniqueness: { - scope: :campaign, - message: 'campaign email has already been sent' - }, if: -> { campaign.present? } - - validate :campaign_or_track_series - enum track: { create: 0, verify: 1, @@ -44,20 +35,15 @@ module Users INACTIVE_TRACK_NAMES = %w[invite_team experience].freeze ACTIVE_TRACKS = tracks.except(*INACTIVE_TRACK_NAMES) - scope :for_user_with_track_and_series, -> (user, track, series) do + scope :for_user_with_track_and_series, ->(user, track, series) do where(user: user, track: track, series: series) end - scope :without_track_and_series, -> (track, series) do + scope :without_track_and_series, ->(track, series) do join_condition = for_user.and(for_track_and_series(track, series)) users_without_records(join_condition) end - scope :without_campaign, -> (campaign) do - join_condition = for_user.and(for_campaign(campaign)) - users_without_records(join_condition) - end - def self.users_table User.arel_table end @@ -78,10 +64,6 @@ module Users arel_table[:user_id].eq(users_table[:id]) end - def self.for_campaign(campaign) - arel_table[:campaign].eq(campaign) - end - def self.for_track_and_series(track, series) arel_table[:track].eq(ACTIVE_TRACKS[track]) .and(arel_table[:series]).eq(series) @@ -92,13 +74,5 @@ module Users email.update(cta_clicked_at: Time.zone.now) if email && email.cta_clicked_at.blank? end - - private - - def campaign_or_track_series - if campaign.present? && (track.present? || series.present?) - errors.add(:campaign, 'should be a campaign or a track and series but not both') - end - end end end diff --git a/app/models/users/phone_number_validation.rb b/app/models/users/phone_number_validation.rb index 52f16a7861f..e033445d76b 100644 --- a/app/models/users/phone_number_validation.rb +++ b/app/models/users/phone_number_validation.rb @@ -2,9 +2,13 @@ module Users class PhoneNumberValidation < ApplicationRecord + include IgnorableColumns + self.primary_key = :user_id self.table_name = 'user_phone_number_validations' + ignore_column :verification_attempts, remove_with: '16.7', remove_after: '2023-11-17' + belongs_to :user, foreign_key: :user_id belongs_to :banned_user, class_name: '::Users::BannedUser', foreign_key: :user_id diff --git a/app/models/vs_code/settings/vs_code_setting.rb b/app/models/vs_code/settings/vs_code_setting.rb new file mode 100644 index 00000000000..e55d958d2b4 --- /dev/null +++ b/app/models/vs_code/settings/vs_code_setting.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module VsCode + module Settings + class VsCodeSetting < ApplicationRecord + belongs_to :user, inverse_of: :vscode_settings + + validates :setting_type, presence: true + validates :content, presence: true + + scope :by_setting_type, ->(setting_type) { where(setting_type: setting_type) } + scope :by_user, ->(user) { where(user: user) } + end + end +end diff --git a/app/models/vulnerability.rb b/app/models/vulnerability.rb index 650e8942132..0e3fe2cc8ac 100644 --- a/app/models/vulnerability.rb +++ b/app/models/vulnerability.rb @@ -9,6 +9,9 @@ class Vulnerability < ApplicationRecord scope :with_projects, -> { includes(:project) } + validates :cvss, json_schema: { filename: "vulnerability_cvss_vectors", draft: 7 } + attribute :cvss, :ind_jsonb + def self.link_reference_pattern nil end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index a7e2be0eae5..2eed693ca76 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -205,7 +205,7 @@ class WikiPage update_attributes(attrs) save do - wiki.create_page(title, content, format, attrs[:message]) + wiki.create_page(title, raw_content, format, attrs[:message]) end end diff --git a/app/models/work_item.rb b/app/models/work_item.rb index 62b837eeeb6..0761a213532 100644 --- a/app/models/work_item.rb +++ b/app/models/work_item.rb @@ -148,6 +148,8 @@ class WorkItem < Issue end def linked_work_items(current_user = nil, authorize: true, preload: nil, link_type: nil) + return [] if new_record? + linked_work_items = linked_work_items_query(link_type).preload(preload).reorder('issue_link_id') return linked_work_items unless authorize @@ -159,6 +161,10 @@ class WorkItem < Issue ) end + def linked_items_count + linked_work_items(authorize: false).size + end + private override :parent_link_confidentiality diff --git a/app/models/work_items/parent_link.rb b/app/models/work_items/parent_link.rb index ea7755b03b4..32232c93d11 100644 --- a/app/models/work_items/parent_link.rb +++ b/app/models/work_items/parent_link.rb @@ -15,7 +15,6 @@ module WorkItems validates :work_item, presence: true, uniqueness: true validate :validate_hierarchy_restrictions validate :validate_cyclic_reference - validate :validate_same_project validate :validate_max_children validate :validate_confidentiality validate :check_existing_related_link @@ -50,14 +49,6 @@ module WorkItems private - def validate_same_project - return if work_item.nil? || work_item_parent.nil? - - if work_item.resource_parent != work_item_parent.resource_parent - errors.add :work_item_parent, _('parent must be in the same project as child.') - end - end - def validate_max_children return unless work_item_parent @@ -88,6 +79,14 @@ module WorkItems end validate_depth(restriction.maximum_depth) + validate_cross_hierarchy(restriction.cross_hierarchy_enabled) + end + + def validate_cross_hierarchy(cross_hierarchy_enabled) + return if cross_hierarchy_enabled + return if work_item.resource_parent == work_item_parent.resource_parent + + errors.add :work_item_parent, _('parent must be in the same project or group as child.') end def validate_depth(depth) diff --git a/app/models/work_items/related_link_restriction.rb b/app/models/work_items/related_link_restriction.rb new file mode 100644 index 00000000000..d4a66c95ffb --- /dev/null +++ b/app/models/work_items/related_link_restriction.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module WorkItems + class RelatedLinkRestriction < ApplicationRecord + self.table_name = 'work_item_related_link_restrictions' + + belongs_to :source_type, class_name: 'WorkItems::Type' + belongs_to :target_type, class_name: 'WorkItems::Type' + + validates :source_type, presence: true + validates :target_type, presence: true + validates :target_type, uniqueness: { scope: [:source_type_id, :link_type] } + + enum link_type: Enums::IssuableLink.link_types + end +end diff --git a/app/models/work_items/related_work_item_link.rb b/app/models/work_items/related_work_item_link.rb index a911ef5f05d..fb0069541fb 100644 --- a/app/models/work_items/related_work_item_link.rb +++ b/app/models/work_items/related_work_item_link.rb @@ -11,7 +11,7 @@ module WorkItems belongs_to :source, class_name: 'WorkItem' belongs_to :target, class_name: 'WorkItem' - validate :validate_max_number_of_links, on: :create + validate :validate_related_link_restrictions class << self extend ::Gitlab::Utils::Override @@ -28,14 +28,39 @@ module WorkItems end end - def validate_max_number_of_links - if source && source.linked_work_items(authorize: false).size >= MAX_LINKS_COUNT - errors.add :source, s_('WorkItems|This work item would exceed the maximum number of linked items.') - end + private + + def validate_related_link_restrictions + return unless source && target + + source_type = source.work_item_type + target_type = target.work_item_type - return unless target && target.linked_work_items(authorize: false).size >= MAX_LINKS_COUNT + return if link_restriction_exists?(source_type.id, target_type.id) - errors.add :target, s_('WorkItems|This work item would exceed the maximum number of linked items.') + errors.add :source, format( + s_('%{source_type} cannot be related to %{type_type}'), + source_type: source_type.name.downcase.pluralize, + type_type: target_type.name.downcase.pluralize + ) + end + + def link_restriction_exists?(source_type_id, target_type_id) + source_restriction = find_restriction(source_type_id, target_type_id) + return true if source_restriction.present? + return false if source_type_id == target_type_id + + find_restriction(target_type_id, source_type_id).present? + end + + def find_restriction(source_type_id, target_type_id) + ::WorkItems::RelatedLinkRestriction.find_by_source_type_id_and_target_type_id_and_link_type( + source_type_id, + target_type_id, + link_type + ) end end end + +WorkItems::RelatedWorkItemLink.prepend_mod diff --git a/app/models/work_items/type.rb b/app/models/work_items/type.rb index b7ceeecbc7f..4ccef4c93d3 100644 --- a/app/models/work_items/type.rb +++ b/app/models/work_items/type.rb @@ -73,6 +73,7 @@ module WorkItems Gitlab::DatabaseImporters::WorkItems::BaseTypeImporter.upsert_types Gitlab::DatabaseImporters::WorkItems::HierarchyRestrictionsImporter.upsert_restrictions + Gitlab::DatabaseImporters::WorkItems::RelatedLinksRestrictionsImporter.upsert_restrictions find_by(namespace_id: nil, base_type: type) end diff --git a/app/models/work_items/widgets/hierarchy.rb b/app/models/work_items/widgets/hierarchy.rb index 8f54cb32f43..fc6714f1e08 100644 --- a/app/models/work_items/widgets/hierarchy.rb +++ b/app/models/work_items/widgets/hierarchy.rb @@ -10,6 +10,26 @@ module WorkItems def children work_item.work_item_children_by_relative_position end + + def ancestors + work_item.ancestors + end + + def self.quick_action_commands + [:set_parent, :add_child] + end + + def self.quick_action_params + [:set_parent, :add_child] + end + + def self.process_quick_action_param(param_name, value) + return super unless param_name.in?(quick_action_params) && value.present? + + return { parent: value } if param_name == :set_parent + + return { children: value } if param_name == :add_child + end end end end diff --git a/app/policies/achievements/user_achievement_policy.rb b/app/policies/achievements/user_achievement_policy.rb index 05650a05490..2710c9c0a5b 100644 --- a/app/policies/achievements/user_achievement_policy.rb +++ b/app/policies/achievements/user_achievement_policy.rb @@ -5,8 +5,17 @@ module Achievements delegate { @subject.achievement.namespace } delegate { @subject.user } + condition(:user_is_owner) { @subject.user == @user } + rule { can?(:read_user_profile) | can?(:admin_achievement) }.enable :read_user_achievement - rule { ~can?(:read_achievement) }.prevent :read_user_achievement + rule { user_is_owner }.enable :update_owned_user_achievement + + rule { can?(:update_owned_user_achievement) }.enable :update_user_achievement + + rule { ~can?(:read_achievement) }.policy do + prevent :read_user_achievement + prevent :update_user_achievement + end end end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index faa83019bda..2ab59f5a34d 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -72,10 +72,6 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy access_level(for_any_session: true) >= GroupMember::GUEST || valid_dependency_proxy_deploy_token end - condition(:observability_enabled, scope: :subject) do - Feature.enabled?(:observability_group_tab, @subject) - end - desc "Deploy token with read_package_registry scope" condition(:read_package_registry_deploy_token) do @user.is_a?(DeployToken) && @user.groups.include?(@subject) && @user.read_package_registry @@ -174,7 +170,9 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy prevent :read_design_activity end - rule { has_access }.enable :read_namespace + rule { has_access }.enable :read_namespace_via_membership + + rule { can?(:read_namespace_via_membership) }.enable :read_namespace rule { developer }.policy do enable :admin_metrics_dashboard_annotation @@ -364,14 +362,6 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :destroy_resource_access_tokens end - rule { can?(:developer_access) & observability_enabled }.policy do - enable :read_observability - end - - rule { can?(:maintainer_access) & observability_enabled }.policy do - enable :admin_observability - end - # Should be matched with ProjectPolicy#read_internal_note rule { admin | reporter }.enable :read_internal_note diff --git a/app/policies/identity_provider_policy.rb b/app/policies/identity_provider_policy.rb index 1e748c78555..5baa96b37ee 100644 --- a/app/policies/identity_provider_policy.rb +++ b/app/policies/identity_provider_policy.rb @@ -14,4 +14,6 @@ class IdentityProviderPolicy < BasePolicy rule { protected_provider }.prevent(:unlink) end -IdentityProviderPolicy.prepend_mod_with('IdentityProviderPolicy') +# Added for JiHu +# https://gitlab.com/gitlab-org/gitlab/-/merge_requests/127672#note_1568398967 +IdentityProviderPolicy.prepend_mod diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb index 538959c92bd..6114785a851 100644 --- a/app/policies/issue_policy.rb +++ b/app/policies/issue_policy.rb @@ -29,6 +29,12 @@ class IssuePolicy < IssuablePolicy @subject.work_item_type.widgets.include?(::WorkItems::Widgets::Notes) end + condition(:group_issue, scope: :subject) { subject_container.is_a?(Group) } + + rule { group_issue & can?(:read_group) }.policy do + enable :create_note + end + rule { ~notes_widget_enabled }.policy do prevent :create_note prevent :read_note diff --git a/app/policies/namespaces/group_project_namespace_shared_policy.rb b/app/policies/namespaces/group_project_namespace_shared_policy.rb index 2214839fb62..b24cb5be607 100644 --- a/app/policies/namespaces/group_project_namespace_shared_policy.rb +++ b/app/policies/namespaces/group_project_namespace_shared_policy.rb @@ -23,6 +23,7 @@ module Namespaces enable :read_work_item enable :read_issue enable :read_namespace + enable :read_namespace_via_membership end rule { can?(:create_work_item) }.enable :create_task diff --git a/app/policies/namespaces/user_namespace_policy.rb b/app/policies/namespaces/user_namespace_policy.rb index bfed61e72d3..f2ac0f0403d 100644 --- a/app/policies/namespaces/user_namespace_policy.rb +++ b/app/policies/namespaces/user_namespace_policy.rb @@ -14,6 +14,7 @@ module Namespaces enable :import_projects enable :admin_namespace enable :read_namespace + enable :read_namespace_via_membership enable :read_statistics enable :create_jira_connect_subscription enable :admin_package diff --git a/app/policies/packages/protection/rule_policy.rb b/app/policies/packages/protection/rule_policy.rb new file mode 100644 index 00000000000..fdf269e04cf --- /dev/null +++ b/app/policies/packages/protection/rule_policy.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Packages + module Protection + class RulePolicy < BasePolicy + delegate { @subject.project } + end + end +end diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb index 205dad6ea5f..7ee258f962a 100644 --- a/app/policies/personal_snippet_policy.rb +++ b/app/policies/personal_snippet_policy.rb @@ -4,6 +4,7 @@ class PersonalSnippetPolicy < BasePolicy condition(:public_snippet, scope: :subject) { @subject.public? } condition(:is_author) { @user && @subject.author == @user } condition(:internal_snippet, scope: :subject) { @subject.internal? } + condition(:hidden, scope: :subject) { @subject.hidden_due_to_author_ban? } rule { public_snippet }.policy do enable :read_snippet @@ -29,5 +30,13 @@ class PersonalSnippetPolicy < BasePolicy rule { can?(:create_note) }.enable :award_emoji + rule { hidden & ~can?(:read_all_resources) }.policy do + prevent :read_snippet + prevent :update_snippet + prevent :admin_snippet + prevent :read_note + prevent :create_note + end + rule { can?(:read_all_resources) }.enable :read_snippet end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index a57b6f8daf7..20f88577d67 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -642,7 +642,6 @@ class ProjectPolicy < BasePolicy prevent(*create_read_update_admin_destroy(:build)) prevent(*create_read_update_admin_destroy(:pipeline_schedule)) prevent(*create_read_update_admin_destroy(:environment)) - prevent(*create_read_update_admin_destroy(:cluster)) prevent(*create_read_update_admin_destroy(:deployment)) end @@ -666,6 +665,7 @@ class ProjectPolicy < BasePolicy prevent :read_pipeline_schedule prevent(*create_read_update_admin_destroy(:feature_flag)) prevent(:admin_feature_flags_user_lists) + prevent(*create_read_update_admin_destroy(:cluster)) end rule { container_registry_disabled }.policy do @@ -695,7 +695,6 @@ class ProjectPolicy < BasePolicy enable :read_merge_request enable :read_note enable :read_pipeline - enable :read_pipeline_schedule enable :read_environment enable :read_deployment enable :read_commit_status @@ -712,7 +711,10 @@ class ProjectPolicy < BasePolicy enable :read_issue end - rule { can?(:public_access) & public_builds }.enable :read_ci_cd_analytics + rule { can?(:public_access) & public_builds }.policy do + enable :read_ci_cd_analytics + enable :read_pipeline_schedule + end rule { public_builds }.policy do enable :read_build diff --git a/app/policies/project_snippet_policy.rb b/app/policies/project_snippet_policy.rb index e11c1a39757..214e48d8841 100644 --- a/app/policies/project_snippet_policy.rb +++ b/app/policies/project_snippet_policy.rb @@ -8,7 +8,7 @@ class ProjectSnippetPolicy < BasePolicy condition(:internal_snippet, scope: :subject) { @subject.internal? } condition(:private_snippet, scope: :subject) { @subject.private? } condition(:public_project, scope: :subject) { @subject.project.public? } - + condition(:hidden, scope: :subject) { @subject.hidden_due_to_author_ban? } condition(:is_author) { @user && @subject.author == @user } # We have to check both project feature visibility and a snippet visibility and take the stricter one @@ -50,6 +50,13 @@ class ProjectSnippetPolicy < BasePolicy enable :admin_snippet end + rule { hidden & ~can?(:read_all_resources) }.policy do + prevent :read_snippet + prevent :update_snippet + prevent :admin_snippet + prevent :read_note + end + rule { ~can?(:read_snippet) }.prevent :create_note end diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb index 6f32f4de62c..c52fc168c55 100644 --- a/app/presenters/blob_presenter.rb +++ b/app/presenters/blob_presenter.rb @@ -90,11 +90,11 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated end def blame_path - url_helpers.project_blame_path(project, ref_qualified_path) + url_helpers.project_blame_path(*path_params) end def history_path - url_helpers.project_commits_path(project, ref_qualified_path) + url_helpers.project_commits_path(*path_params) end def permalink_path @@ -193,7 +193,7 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated def commit_id # If `ref_type` is present the commit_id will include the ref qualifier e.g. `refs/heads/`. # We only accept/return unqualified refs so we need to remove the qualifier from the `commit_id`. - ExtractsRef.unqualify_ref(blob.commit_id, ref_type) + ExtractsRef::RefExtractor.unqualify_ref(blob.commit_id, ref_type) end def ref_qualified_path diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index 762ee0d92cd..0bf4a99dcba 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -90,7 +90,7 @@ module Ci def link_to_pipeline_ref ApplicationController.helpers.link_to(pipeline.ref, project_commits_path(pipeline.project, pipeline.ref), - class: "ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2") + class: "ref-container gl-link") end def link_to_merge_request @@ -98,7 +98,7 @@ module Ci ApplicationController.helpers.link_to(merge_request_presenter.to_reference, project_merge_request_path(merge_request_presenter.project, merge_request_presenter), - class: 'mr-iid') + class: 'mr-iid ref-container') end def link_to_merge_request_source_branch @@ -120,6 +120,10 @@ module Ci end end + def triggered_by_path + pipeline.child? ? project_pipeline_path(pipeline.triggered_by_pipeline.project, pipeline.triggered_by_pipeline) : '' + end + private def plain_ref_name @@ -133,26 +137,6 @@ module Ci end end end - - def all_related_merge_request_links(limit: nil) - limit ||= all_related_merge_requests.count - - all_related_merge_requests.first(limit).map do |merge_request| - mr_path = project_merge_request_path(merge_request.project, merge_request) - - ApplicationController.helpers.link_to "#{merge_request.to_reference} #{merge_request.title}", mr_path, class: 'mr-iid' - end - end - - def all_related_merge_requests - strong_memoize(:all_related_merge_requests) do - if pipeline.ref && can?(current_user, :read_merge_request, pipeline.project) - pipeline.all_merge_requests_by_recency.to_a - else - [] - end - end - end end end diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index 5c23af6e821..09845574aa1 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -197,7 +197,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated def source_branch_link if source_branch_exists? - link_to(source_branch, source_branch_commits_path, class: 'ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2') + link_to(source_branch, source_branch_commits_path, class: 'ref-container gl-link') else content_tag(:span, source_branch, class: 'ref-name') end @@ -205,7 +205,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated def target_branch_link if target_branch_exists? - link_to(target_branch, target_branch_commits_path, class: 'ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2') + link_to(target_branch, target_branch_commits_path, class: 'ref-container gl-link') else content_tag(:span, target_branch, class: 'ref-name') end diff --git a/app/presenters/ml/candidate_details_presenter.rb b/app/presenters/ml/candidate_details_presenter.rb index 29d4617903f..057d3bd19d9 100644 --- a/app/presenters/ml/candidate_details_presenter.rb +++ b/app/presenters/ml/candidate_details_presenter.rb @@ -23,7 +23,7 @@ module Ml ci_job: job_info }, params: candidate.params, - metrics: candidate.latest_metrics, + metrics: candidate.metrics, metadata: candidate.metadata } } diff --git a/app/presenters/ml/model_presenter.rb b/app/presenters/ml/model_presenter.rb index 1317a13351b..388e2b73bc1 100644 --- a/app/presenters/ml/model_presenter.rb +++ b/app/presenters/ml/model_presenter.rb @@ -13,5 +13,9 @@ module Ml Gitlab::Routing.url_helpers.project_package_path(model.project, model.latest_version.package_id) end + + def path + Gitlab::Routing.url_helpers.project_ml_model_path(model.project, model.id) + end end end diff --git a/app/presenters/tree_entry_presenter.rb b/app/presenters/tree_entry_presenter.rb index 3f4a9f13c36..674fc3ee322 100644 --- a/app/presenters/tree_entry_presenter.rb +++ b/app/presenters/tree_entry_presenter.rb @@ -19,7 +19,7 @@ class TreeEntryPresenter < Gitlab::View::Presenter::Delegated # If `ref_type` is present the commit_id will include the ref qualifier e.g. `refs/heads/`. # We only accept/return unqualified refs so we need to remove the qualifier from the `commit_id`. - commit_id = ExtractsRef.unqualify_ref(tree.commit_id, ref_type) + commit_id = ExtractsRef::RefExtractor.unqualify_ref(tree.commit_id, ref_type) File.join(commit_id, tree.path) end diff --git a/app/presenters/vs_code/settings/vs_code_manifest_presenter.rb b/app/presenters/vs_code/settings/vs_code_manifest_presenter.rb new file mode 100644 index 00000000000..a8cb5de10a2 --- /dev/null +++ b/app/presenters/vs_code/settings/vs_code_manifest_presenter.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module VsCode + module Settings + class VsCodeManifestPresenter < Gitlab::View::Presenter::Simple + attr_reader :settings + + def initialize(settings) + @settings = settings + end + + def latest + latest_settings_map = {} + # There is a default machine stored + latest_settings_map['machines'] = DEFAULT_MACHINE[:uuid] + + return latest_settings_map if settings.empty? + + persisted_settings = settings.each_with_object({}) do |setting, hash| + hash[setting.setting_type] = setting.uuid + end + + latest_settings_map.merge(persisted_settings) + end + + def session + DEFAULT_SESSION + end + end + end +end diff --git a/app/presenters/vs_code/settings/vs_code_setting_presenter.rb b/app/presenters/vs_code/settings/vs_code_setting_presenter.rb new file mode 100644 index 00000000000..246bebf6099 --- /dev/null +++ b/app/presenters/vs_code/settings/vs_code_setting_presenter.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module VsCode + module Settings + class VsCodeSettingPresenter < Gitlab::View::Presenter::Simple + attr_reader :setting + + def initialize(setting) + @setting = setting + end + + def content + @setting[:setting_type] == 'machines' ? nil : @setting.content + end + + def machines + @setting[:setting_type] == 'machines' ? @setting[:machines] : nil + end + + def version + @setting[:version] + end + + def machine_id + DEFAULT_MACHINE[:uuid] if @setting[:setting_type] != 'machines' + end + end + end +end diff --git a/app/serializers/admin/abuse_report_details_entity.rb b/app/serializers/admin/abuse_report_details_entity.rb index 8a67aabda9e..77b85f239f7 100644 --- a/app/serializers/admin/abuse_report_details_entity.rb +++ b/app/serializers/admin/abuse_report_details_entity.rb @@ -35,10 +35,7 @@ module Admin end end - expose :credit_card, if: ->(report) { report.user.credit_card_validation&.holder_name } do - expose :name do |report| - report.user.credit_card_validation.holder_name - end + expose :credit_card, if: ->(report) { report.user.credit_card_validation.present? } do expose :similar_records_count do |report| report.user.credit_card_validation.similar_records.count end diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb index 741643f7989..9aee031328b 100644 --- a/app/serializers/build_details_entity.rb +++ b/app/serializers/build_details_entity.rb @@ -139,10 +139,11 @@ class BuildDetailsEntity < Ci::JobEntity return super unless build.failure_reason.to_sym == :missing_dependency_failure docs_url = "https://docs.gitlab.com/ee/ci/yaml/index.html#dependencies" + troubleshooting_url = "https://docs.gitlab.com/ee/ci/jobs/job_artifacts_troubleshooting.html#error-message-this-job-could-not-start-because-it-could-not-retrieve-the-needed-artifacts" [ failure_message, - help_message(docs_url).html_safe + help_message(docs_url, troubleshooting_url).html_safe ].join("<br />") end @@ -157,8 +158,8 @@ class BuildDetailsEntity < Ci::JobEntity { invalid_dependencies: html_escape(invalid_dependencies), punctuation: punctuation } end - def help_message(docs_url) - html_escape(_("<a href=\"#{docs_url}\">Learn more.</a>".html_safe)) + def help_message(docs_url, troubleshooting_url) + html_escape(_("Learn more about <a href=\"#{docs_url}\">dependencies</a> and <a href=\"#{troubleshooting_url}\">common causes</a> of this error.</a>".html_safe)) end end diff --git a/app/serializers/ci/pipeline_entity.rb b/app/serializers/ci/pipeline_entity.rb index 28baa64bc7c..832ca619edc 100644 --- a/app/serializers/ci/pipeline_entity.rb +++ b/app/serializers/ci/pipeline_entity.rb @@ -33,7 +33,8 @@ class Ci::PipelineEntity < Grape::Entity expose :can_cancel?, as: :cancelable expose :failure_reason?, as: :failure_reason expose :detached_merge_request_pipeline?, as: :detached_merge_request_pipeline - expose :merged_result_pipeline?, as: :merge_request_pipeline + expose :merged_result_pipeline?, as: :merge_request_pipeline # deprecated, use merged_result_pipeline going forward + expose :merged_result_pipeline?, as: :merged_result_pipeline end expose :details do @@ -83,12 +84,18 @@ class Ci::PipelineEntity < Grape::Entity project_pipeline_path(pipeline.project, pipeline) end - expose :failed_builds, if: -> (*) { can_retry? }, using: Ci::JobEntity do |pipeline| + expose :failed_builds, + if: -> (_, options) { !options[:disable_failed_builds] && can_retry? }, + using: Ci::JobEntity do |pipeline| pipeline.failed_builds.each do |build| build.project = pipeline.project end end + expose :failed_builds_count do |pipeline| + pipeline.failed_builds.size + end + private alias_method :pipeline, :object diff --git a/app/serializers/merge_request_noteable_entity.rb b/app/serializers/merge_request_noteable_entity.rb index 306bac7daae..aac90c20b53 100644 --- a/app/serializers/merge_request_noteable_entity.rb +++ b/app/serializers/merge_request_noteable_entity.rb @@ -15,8 +15,8 @@ class MergeRequestNoteableEntity < IssuableEntity project_tree_path(merge_request.source_project, merge_request.source_branch) end - expose :target_branch_path, if: -> (merge_request) { merge_request.source_project } do |merge_request| - project_tree_path(merge_request.source_project, merge_request.target_branch) + expose :target_branch_path, if: -> (merge_request) { merge_request.target_project } do |merge_request| + project_tree_path(merge_request.target_project, merge_request.target_branch) end expose :diff_head_sha diff --git a/app/serializers/merge_requests/pipeline_entity.rb b/app/serializers/merge_requests/pipeline_entity.rb index 500dc435526..83f168682db 100644 --- a/app/serializers/merge_requests/pipeline_entity.rb +++ b/app/serializers/merge_requests/pipeline_entity.rb @@ -12,7 +12,8 @@ class MergeRequests::PipelineEntity < Grape::Entity end expose :flags do - expose :merged_result_pipeline?, as: :merge_request_pipeline + expose :merged_result_pipeline?, as: :merge_request_pipeline # deprecated, use merged_result_pipeline going forward + expose :merged_result_pipeline?, as: :merged_result_pipeline end expose :commit, using: CommitEntity diff --git a/app/serializers/project_import_entity.rb b/app/serializers/project_import_entity.rb index 302086143c1..e5d1b84b7e4 100644 --- a/app/serializers/project_import_entity.rb +++ b/app/serializers/project_import_entity.rb @@ -19,7 +19,7 @@ class ProjectImportEntity < ProjectEntity # Only for GitHub importer where we pass client through expose :relation_type do |project, options| - next nil if options[:client].nil? || Feature.disabled?(:remove_legacy_github_client) + next nil if options[:client].nil? ::Gitlab::GithubImport::ProjectRelationType.new(options[:client]).for(project.import_source) end diff --git a/app/services/achievements/update_user_achievement_priorities_service.rb b/app/services/achievements/update_user_achievement_priorities_service.rb new file mode 100644 index 00000000000..1165a1b3bf6 --- /dev/null +++ b/app/services/achievements/update_user_achievement_priorities_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Achievements + class UpdateUserAchievementPrioritiesService + attr_reader :current_user, :user_achievements + + def initialize(current_user, user_achievements) + @current_user = current_user + @user_achievements = user_achievements + end + + def execute + return error_no_permissions unless allowed? + + prioritized_user_achievements_map = Hash[user_achievements.map.with_index { |ua, idx| [ua.id, idx] }] + + user_achievements_priorities_mapping = current_user.user_achievements.each_with_object({}) do |ua, result| + next if ua.priority.nil? && !prioritized_user_achievements_map.key?(ua.id) + + result[ua] = { priority: prioritized_user_achievements_map.fetch(ua.id, nil) } + end + + return ServiceResponse.success(payload: []) if user_achievements_priorities_mapping.empty? + + ::Gitlab::Database::BulkUpdate.execute(%i[priority], user_achievements_priorities_mapping) + + ServiceResponse.success(payload: user_achievements_priorities_mapping.keys.map(&:reload)) + end + + private + + def allowed? + user_achievements.all? { |user_achievement| current_user&.can?(:update_owned_user_achievement, user_achievement) } + end + + def error(message) + ServiceResponse.error(payload: user_achievements, message: Array(message)) + end + + def error_no_permissions + error("You can't update at least one of the given user achievements.") + end + end +end diff --git a/app/services/admin/abuse_reports/moderate_user_service.rb b/app/services/admin/abuse_reports/moderate_user_service.rb index 823568d9db8..1e14806c694 100644 --- a/app/services/admin/abuse_reports/moderate_user_service.rb +++ b/app/services/admin/abuse_reports/moderate_user_service.rb @@ -42,6 +42,7 @@ module Admin when :block_user then block_user when :delete_user then delete_user when :close_report then close_report + when :trust_user then trust_user end end @@ -66,6 +67,10 @@ module Admin success end + def trust_user + Users::TrustService.new(current_user).execute(abuse_report.user) + end + def close_similar_open_reports # admins see the abuse report and other open reports for the same user in one page # hence, if the request is to close the report, close other open reports for the same user too diff --git a/app/services/audit_events/build_service.rb b/app/services/audit_events/build_service.rb index f5322fa5ff4..9eab2f836db 100644 --- a/app/services/audit_events/build_service.rb +++ b/app/services/audit_events/build_service.rb @@ -11,7 +11,10 @@ module AuditEvents def initialize( author:, scope:, target:, message:, created_at: DateTime.current, additional_details: {}, ip_address: nil, target_details: nil) - raise MissingAttributeError if missing_attribute?(author, scope, target, message) + raise MissingAttributeError, "author" if author.blank? + raise MissingAttributeError, "scope" if scope.blank? + raise MissingAttributeError, "target" if target.blank? + raise MissingAttributeError, "message" if message.blank? @author = build_author(author) @scope = scope @@ -32,10 +35,6 @@ module AuditEvents private - def missing_attribute?(author, scope, target, message) - author.blank? || scope.blank? || target.blank? || message.blank? - end - def payload base_payload.merge(details: base_details_payload) end diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb index 9b010272995..363510a41a1 100644 --- a/app/services/auth/container_registry_authentication_service.rb +++ b/app/services/auth/container_registry_authentication_service.rb @@ -39,32 +39,45 @@ module Auth end def self.full_access_token(*names) - access_token(%w[*], names) + names_and_actions = names.index_with { %w[*] } + access_token(names_and_actions) end def self.import_access_token - access_token(%w[*], ['import'], 'registry') + access_token({ 'import' => %w[*] }, 'registry') end def self.pull_access_token(*names) - access_token(['pull'], names) + names_and_actions = names.index_with { %w[pull] } + access_token(names_and_actions) end def self.pull_nested_repositories_access_token(name) - name = name.chomp('/') if name.end_with?('/') - paths = [name, "#{name}/*"] - access_token(['pull'], paths) + name = name.chomp('/') + + access_token({ + name => %w[pull], + "#{name}/*" => %w[pull] + }) + end + + def self.push_pull_nested_repositories_access_token(name) + name = name.chomp('/') + + access_token({ + name => %w[pull push], + "#{name}/*" => %w[pull] + }) end - def self.access_token(actions, names, type = 'repository') - names = names.flatten + def self.access_token(names_and_actions, type = 'repository') registry = Gitlab.config.registry token = JSONWebToken::RSAToken.new(registry.key) token.issuer = registry.issuer token.audience = AUDIENCE token.expire_time = token_expire_at - token[:access] = names.map do |name| + token[:access] = names_and_actions.map do |name, actions| { type: type, name: name, @@ -219,7 +232,6 @@ module Auth # Overridden in EE def can_access?(requested_project, requested_action) return false unless requested_project.container_registry_enabled? - return false if requested_project.repository_access_level == ::ProjectFeature::DISABLED case requested_action when 'pull' diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb index 77ed0369624..d0fde43138a 100644 --- a/app/services/auto_merge/base_service.rb +++ b/app/services/auto_merge/base_service.rb @@ -61,9 +61,9 @@ module AutoMerge merge_request.can_be_merged_by?(current_user) && merge_request.open? && !merge_request.broken? && - !merge_request.draft? && - merge_request.mergeable_discussions_state? && - !merge_request.merge_blocked_by_other_mrs? && + (skip_draft_check(merge_request) || !merge_request.draft?) && + (skip_discussions_check(merge_request) || merge_request.mergeable_discussions_state?) && + (skip_blocked_check(merge_request) || !merge_request.merge_blocked_by_other_mrs?) && yield end end @@ -109,5 +109,20 @@ module AutoMerge def track_exception(error, merge_request) Gitlab::ErrorTracking.track_exception(error, merge_request_id: merge_request&.id) end + + # Will skip the draft check or not when checking if strategy is available + def skip_draft_check(merge_request) + false + end + + # Will skip the blocked check or not when checking if strategy is available + def skip_blocked_check(merge_request) + false + end + + # Will skip the discussions check or not when checking if strategy is available + def skip_discussions_check(merge_request) + false + end end end diff --git a/app/services/branches/delete_service.rb b/app/services/branches/delete_service.rb index 6efbdd161a1..e396d784ca6 100644 --- a/app/services/branches/delete_service.rb +++ b/app/services/branches/delete_service.rb @@ -37,3 +37,5 @@ module Branches end end end + +Branches::DeleteService.prepend_mod diff --git a/app/services/bulk_create_integration_service.rb b/app/services/bulk_create_integration_service.rb index 8fbb7f4f347..70c77444f13 100644 --- a/app/services/bulk_create_integration_service.rb +++ b/app/services/bulk_create_integration_service.rb @@ -10,10 +10,10 @@ class BulkCreateIntegrationService end def execute - service_list = ServiceList.new(batch, integration_hash(:create), association).to_array + integration_list = Integrations::IntegrationList.new(batch, integration_hash(:create), association).to_array Integration.transaction do - results = bulk_insert(*service_list) + results = bulk_insert(*integration_list) if integration.data_fields_present? data_list = DataList.new(results, data_fields_hash(:create), integration.data_fields.class).to_array diff --git a/app/services/bulk_imports/process_service.rb b/app/services/bulk_imports/process_service.rb new file mode 100644 index 00000000000..14c5545cfd5 --- /dev/null +++ b/app/services/bulk_imports/process_service.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +module BulkImports + class ProcessService + PERFORM_DELAY = 5.seconds + DEFAULT_BATCH_SIZE = 5 + + attr_reader :bulk_import + + def initialize(bulk_import) + @bulk_import = bulk_import + end + + def execute + return unless bulk_import + return if bulk_import.completed? + return bulk_import.fail_op! if all_entities_failed? + return bulk_import.finish! if all_entities_processed? && bulk_import.started? + return re_enqueue if max_batch_size_exceeded? # Do not start more jobs if max allowed are already running + + process_bulk_import + re_enqueue + rescue StandardError => e + Gitlab::ErrorTracking.track_exception(e, bulk_import_id: bulk_import.id) + + bulk_import.fail_op + end + + private + + def process_bulk_import + bulk_import.start! if bulk_import.created? + + created_entities.first(next_batch_size).each do |entity| + create_tracker(entity) + + entity.start! + + BulkImports::ExportRequestWorker.perform_async(entity.id) + end + end + + def entities + @entities ||= bulk_import.entities + end + + def created_entities + entities.with_status(:created) + end + + def all_entities_processed? + entities.all? { |entity| entity.finished? || entity.failed? } + end + + def all_entities_failed? + entities.all?(&:failed?) + end + + # A new BulkImportWorker job is enqueued to either + # - Process the new BulkImports::Entity created during import (e.g. for the subgroups) + # - Or to mark the `bulk_import` as finished + def re_enqueue + BulkImportWorker.perform_in(PERFORM_DELAY, bulk_import.id) + end + + def started_entities + entities.with_status(:started) + end + + def max_batch_size_exceeded? + started_entities.count >= DEFAULT_BATCH_SIZE + end + + def next_batch_size + [DEFAULT_BATCH_SIZE - started_entities.count, 0].max + end + + def create_tracker(entity) + entity.class.transaction do + entity.pipelines.each do |pipeline| + status = skip_pipeline?(pipeline, entity) ? :skipped : :created + + entity.trackers.create!( + stage: pipeline[:stage], + pipeline_name: pipeline[:pipeline], + status: BulkImports::Tracker.state_machine.states[status].value + ) + end + end + end + + def skip_pipeline?(pipeline, entity) + return false unless entity.source_version.valid? + + minimum_version, maximum_version = pipeline.values_at(:minimum_source_version, :maximum_source_version) + + if source_version_out_of_range?(minimum_version, maximum_version, entity.source_version.without_patch) + log_skipped_pipeline(pipeline, entity, minimum_version, maximum_version) + return true + end + + false + end + + def source_version_out_of_range?(minimum_version, maximum_version, non_patch_source_version) + (minimum_version && non_patch_source_version < Gitlab::VersionInfo.parse(minimum_version)) || + (maximum_version && non_patch_source_version > Gitlab::VersionInfo.parse(maximum_version)) + end + + def log_skipped_pipeline(pipeline, entity, minimum_version, maximum_version) + logger.info( + message: 'Pipeline skipped as source instance version not compatible with pipeline', + 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[:pipeline], + minimum_source_version: minimum_version, + maximum_source_version: maximum_version, + source_version: entity.source_version.to_s, + importer: 'gitlab_migration' + ) + end + + def logger + @logger ||= Gitlab::Import::Logger.build + end + end +end diff --git a/app/services/bulk_imports/relation_batch_export_service.rb b/app/services/bulk_imports/relation_batch_export_service.rb index 19eb550216d..c7164d7c304 100644 --- a/app/services/bulk_imports/relation_batch_export_service.rb +++ b/app/services/bulk_imports/relation_batch_export_service.rb @@ -14,6 +14,7 @@ module BulkImports start_batch! export_service.export_batch(relation_batch_ids) + ensure_export_file_exists! compress_exported_relation upload_compressed_file @@ -76,5 +77,15 @@ module BulkImports batch.update!(status_event: 'fail_op', error: exception.message.truncate(255)) end + + def exported_filepath + File.join(export_path, exported_filename) + end + + # Create empty file on disk + # if relation is empty and nothing was exported + def ensure_export_file_exists! + FileUtils.touch(exported_filepath) + end end end diff --git a/app/services/bulk_imports/relation_export_service.rb b/app/services/bulk_imports/relation_export_service.rb index ed71c09420b..91640496440 100644 --- a/app/services/bulk_imports/relation_export_service.rb +++ b/app/services/bulk_imports/relation_export_service.rb @@ -18,6 +18,7 @@ module BulkImports find_or_create_export! do |export| export.remove_existing_upload! export_service.execute + ensure_export_file_exists! compress_exported_relation upload_compressed_file(export) end @@ -91,5 +92,15 @@ module BulkImports export&.update(status_event: 'fail_op', error: exception.class, batched: false) end + + def exported_filepath + File.join(export_path, export_service.exported_filename) + end + + # Create empty file on disk + # if relation is empty and nothing was exported + def ensure_export_file_exists! + FileUtils.touch(exported_filepath) + end end end diff --git a/app/services/chat_names/find_user_service.rb b/app/services/chat_names/find_user_service.rb index 3b204d51bab..a7dc6a47a6b 100644 --- a/app/services/chat_names/find_user_service.rb +++ b/app/services/chat_names/find_user_service.rb @@ -11,7 +11,7 @@ module ChatNames chat_name = find_chat_name return unless chat_name - chat_name.update_last_used_at + record_chat_activity(chat_name) chat_name end @@ -27,5 +27,10 @@ module ChatNames ) end # rubocop: enable CodeReuse/ActiveRecord + + def record_chat_activity(chat_name) + chat_name.update_last_used_at + Users::ActivityService.new(author: chat_name.user).execute + end end end diff --git a/app/services/ci/catalog/resources/validate_service.rb b/app/services/ci/catalog/resources/validate_service.rb new file mode 100644 index 00000000000..9e8986ba6fc --- /dev/null +++ b/app/services/ci/catalog/resources/validate_service.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Ci + module Catalog + module Resources + class ValidateService + attr_reader :project + + def initialize(project, ref) + @project = project + @ref = ref + @errors = [] + end + + def execute + check_project_readme + check_project_description + + if errors.empty? + ServiceResponse.success + else + ServiceResponse.error(message: errors.join(' , ')) + end + end + + private + + attr_reader :ref, :errors + + def check_project_description + return if project.description.present? + + errors << 'Project must have a description' + end + + def check_project_readme + return if project_has_readme? + + errors << 'Project must have a README' + end + + def project_has_readme? + project.repository.blob_data_at(ref, 'README.md') + end + end + end + end +end diff --git a/app/services/ci/catalog/validate_resource_service.rb b/app/services/ci/catalog/validate_resource_service.rb deleted file mode 100644 index f166c220869..00000000000 --- a/app/services/ci/catalog/validate_resource_service.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -module Ci - module Catalog - class ValidateResourceService - attr_reader :project - - def initialize(project, ref) - @project = project - @ref = ref - @errors = [] - end - - def execute - check_project_readme - check_project_description - - if errors.empty? - ServiceResponse.success - else - ServiceResponse.error(message: errors.join(' , ')) - end - end - - private - - attr_reader :ref, :errors - - def check_project_description - return if project.description.present? - - errors << 'Project must have a description' - end - - def check_project_readme - return if project_has_readme? - - errors << 'Project must have a README' - end - - def project_has_readme? - project.repository.blob_data_at(ref, 'README.md') - end - end - end -end diff --git a/app/services/ci/components/fetch_service.rb b/app/services/ci/components/fetch_service.rb index 45abb415174..4f09d47b530 100644 --- a/app/services/ci/components/fetch_service.rb +++ b/app/services/ci/components/fetch_service.rb @@ -5,8 +5,6 @@ module Ci class FetchService include Gitlab::Utils::StrongMemoize - TEMPLATE_FILE = 'template.yml' - COMPONENT_PATHS = [ ::Gitlab::Ci::Components::InstancePath ].freeze @@ -23,11 +21,16 @@ module Ci reason: :unsupported_path) end - component_path = component_path_class.new(address: address, content_filename: TEMPLATE_FILE) - content = component_path.fetch_content!(current_user: current_user) + component_path = component_path_class.new(address: address) + result = component_path.fetch_content!(current_user: current_user) - if content.present? - ServiceResponse.success(payload: { content: content, path: component_path }) + if result + ServiceResponse.success(payload: { + content: result.content, + path: result.path, + project: component_path.project, + sha: component_path.sha + }) else ServiceResponse.error(message: "#{error_prefix} content not found", reason: :content_not_found) end diff --git a/app/services/ci/job_artifacts/destroy_all_expired_service.rb b/app/services/ci/job_artifacts/destroy_all_expired_service.rb index 57b95e59d7d..4d688d79982 100644 --- a/app/services/ci/job_artifacts/destroy_all_expired_service.rb +++ b/app/services/ci/job_artifacts/destroy_all_expired_service.rb @@ -44,10 +44,6 @@ module Ci def destroy_batch(artifacts) Ci::JobArtifacts::DestroyBatchService.new(artifacts, skip_projects_on_refresh: true).execute end - - def loop_timeout? - Time.current > @start_at + LOOP_TIMEOUT - end end end end diff --git a/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb b/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb index 05cd20a152b..c18984953a1 100644 --- a/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb +++ b/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb @@ -19,20 +19,11 @@ module Ci return if pipeline.parent_pipeline? # skip if child pipeline return unless project.auto_cancel_pending_pipelines? - if Feature.enabled?(:use_offset_pagination_for_canceling_redundant_pipelines, project) - paginator.each do |ids| - pipelines = parent_and_child_pipelines(ids) + paginator.each do |ids| + pipelines = parent_and_child_pipelines(ids) - Gitlab::OptimisticLocking.retry_lock(pipelines, name: 'cancel_pending_pipelines') do |cancelables| - auto_cancel_interruptible_pipelines(cancelables.ids) - end - end - else - Gitlab::OptimisticLocking - .retry_lock(parent_and_child_pipelines, name: 'cancel_pending_pipelines') do |cancelables| - cancelables.select(:id).each_batch(of: BATCH_SIZE) do |cancelables_batch| - auto_cancel_interruptible_pipelines(cancelables_batch.ids) - end + Gitlab::OptimisticLocking.retry_lock(pipelines, name: 'cancel_pending_pipelines') do |cancelables| + auto_cancel_interruptible_pipelines(cancelables.ids) end end end @@ -61,7 +52,7 @@ module Ci end end - def parent_auto_cancelable_pipelines(ids = nil) + def parent_auto_cancelable_pipelines(ids) scope = project.all_pipelines .created_after(pipelines_created_after) .for_ref(pipeline.ref) @@ -70,11 +61,10 @@ module Ci .for_status(CommitStatus::AVAILABLE_STATUSES) # Force usage of project_id_and_status_and_created_at_index .ci_sources - scope = scope.id_in(ids) if ids.present? - scope + scope.id_in(ids) end - def parent_and_child_pipelines(ids = nil) + def parent_and_child_pipelines(ids) Ci::Pipeline.object_hierarchy(parent_auto_cancelable_pipelines(ids), project_condition: :same) .base_and_descendants .alive_or_scheduled diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb index 750272c3ecb..84e5089b0d5 100644 --- a/app/services/ci/pipeline_processing/atomic_processing_service.rb +++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb @@ -93,6 +93,8 @@ module Ci # We do not continue to process the job if the previous status is not completed return unless Ci::HasStatus::COMPLETED_STATUSES.include?(previous_status) + ::Deployments::CreateForJobService.new.execute(job) + Gitlab::OptimisticLocking.retry_lock(job, name: 'atomic_processing_update_job') do |subject| Ci::ProcessBuildService.new(project, subject.user) .execute(subject, previous_status) diff --git a/app/services/ci/refs/enqueue_pipelines_to_unlock_service.rb b/app/services/ci/refs/enqueue_pipelines_to_unlock_service.rb new file mode 100644 index 00000000000..319186ce030 --- /dev/null +++ b/app/services/ci/refs/enqueue_pipelines_to_unlock_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Ci + module Refs + class EnqueuePipelinesToUnlockService + include BaseServiceUtility + + BATCH_SIZE = 50 + ENQUEUE_INTERVAL_SECONDS = 0.1 + + def execute(ci_ref, before_pipeline: nil) + pipelines_scope = ci_ref.pipelines.artifacts_locked + pipelines_scope = pipelines_scope.before_pipeline(before_pipeline) if before_pipeline + total_new_entries = 0 + + pipelines_scope.each_batch(of: BATCH_SIZE) do |batch| + pipeline_ids = batch.pluck(:id) # rubocop: disable CodeReuse/ActiveRecord + total_added = Ci::UnlockPipelineRequest.enqueue(pipeline_ids) + total_new_entries += total_added + + # Take a little rest to avoid overloading Redis + sleep ENQUEUE_INTERVAL_SECONDS + end + + success( + total_pending_entries: Ci::UnlockPipelineRequest.total_pending, + total_new_entries: total_new_entries + ) + end + end + end +end diff --git a/app/services/ci/retry_job_service.rb b/app/services/ci/retry_job_service.rb index 14ea09f17a0..d7c3e9e7f64 100644 --- a/app/services/ci/retry_job_service.rb +++ b/app/services/ci/retry_job_service.rb @@ -39,7 +39,9 @@ module Ci ::Ci::CopyCrossDatabaseAssociationsService.new.execute(job, new_job) - ::Deployments::CreateForJobService.new.execute(new_job) + if Feature.disabled?(:create_deployment_only_for_processable_jobs, project) + ::Deployments::CreateForJobService.new.execute(new_job) + end ::MergeRequests::AddTodoWhenBuildFailsService .new(project: project) diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb index 85f910d05d7..f6b2c90c6ec 100644 --- a/app/services/ci/retry_pipeline_service.rb +++ b/app/services/ci/retry_pipeline_service.rb @@ -26,9 +26,7 @@ module Ci .new(project: project, current_user: current_user) .close_all(pipeline) - Ci::ProcessPipelineService - .new(pipeline) - .execute + start_pipeline(pipeline) ServiceResponse.success rescue Gitlab::Access::AccessDeniedError => e @@ -52,6 +50,10 @@ module Ci def can_be_retried?(build) can?(current_user, :update_build, build) end + + def start_pipeline(pipeline) + Ci::PipelineCreation::StartPipelineService.new(pipeline).execute + end end end diff --git a/app/services/ci/unlock_pipeline_service.rb b/app/services/ci/unlock_pipeline_service.rb new file mode 100644 index 00000000000..88d4a8fd0be --- /dev/null +++ b/app/services/ci/unlock_pipeline_service.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +module Ci + class UnlockPipelineService + include BaseServiceUtility + include ::Gitlab::ExclusiveLeaseHelpers + + ExecutionTimeoutError = Class.new(StandardError) + + BATCH_SIZE = 100 + MAX_EXEC_DURATION = 10.minutes.freeze + LOCK_TIMEOUT = (MAX_EXEC_DURATION + 1.minute).freeze + + def initialize(pipeline) + @pipeline = pipeline + @already_leased = false + @already_unlocked = false + @exec_timeout = false + @unlocked_job_artifacts_count = 0 + @unlocked_pipeline_artifacts_count = 0 + end + + def execute + unlock_pipeline_exclusively + + success( + skipped_already_leased: already_leased, + skipped_already_unlocked: already_unlocked, + exec_timeout: exec_timeout, + unlocked_job_artifacts: unlocked_job_artifacts_count, + unlocked_pipeline_artifacts: unlocked_pipeline_artifacts_count + ) + end + + private + + attr_reader :pipeline, :already_leased, :already_unlocked, :exec_timeout, + :unlocked_job_artifacts_count, :unlocked_pipeline_artifacts_count + + def unlock_pipeline_exclusively + in_lock(lock_key, ttl: LOCK_TIMEOUT, retries: 0) do + # Even though we enforce uniqueness when enqueueing pipelines, there is still a rare race condition chance that + # a pipeline can be re-enqueued right after a worker pops off the same pipeline ID from the queue, and then just + # after it completing the unlock process and releasing the lock, another worker picks up the re-enqueued + # pipeline ID. So let's make sure to only unlock artifacts if the pipeline has not been unlocked. + if pipeline.unlocked? + @already_unlocked = true + break + end + + unlock_job_artifacts + unlock_pipeline_artifacts + + # Marking the row in `ci_pipelines` to unlocked signifies that all artifacts have + # already been unlocked. This must always happen last. + unlock_pipeline + end + rescue ExecutionTimeoutError + @exec_timeout = true + rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError + @already_leased = true + ensure + if pipeline.unlocked? + Ci::UnlockPipelineRequest.log_event(:completed, pipeline.id) unless already_unlocked + else + # This is to ensure to re-enqueue the pipeline in 2 occasions: + # 1. When an unexpected error happens. + # 2. When the execution timeout has been reached in the case of a pipeline having a lot of + # job artifacts. This allows us to continue unlocking the rest of the artifacts from + # where we left off. This is why we unlock the pipeline last. + Ci::UnlockPipelineRequest.enqueue(pipeline.id) + Ci::UnlockPipelineRequest.log_event(:re_enqueued, pipeline.id) + end + end + + def lock_key + "ci:unlock_pipeline_service:lock:#{pipeline.id}" + end + + def unlock_pipeline + pipeline.update_column(:locked, Ci::Pipeline.lockeds[:unlocked]) + end + + def unlock_job_artifacts + start = Time.current + + pipeline.builds.each_batch(of: BATCH_SIZE) do |builds| + # rubocop: disable CodeReuse/ActiveRecord + Ci::JobArtifact.where(job_id: builds.pluck(:id)).each_batch(of: BATCH_SIZE) do |job_artifacts| + unlocked_count = Ci::JobArtifact + .where(id: job_artifacts.pluck(:id)) + .update_all(locked: :unlocked) + + @unlocked_job_artifacts_count ||= 0 + @unlocked_job_artifacts_count += unlocked_count + + raise ExecutionTimeoutError if (Time.current - start) > MAX_EXEC_DURATION + end + # rubocop: enable CodeReuse/ActiveRecord + end + end + + def unlock_pipeline_artifacts + @unlocked_pipeline_artifacts_count = pipeline.pipeline_artifacts.update_all(locked: :unlocked) + end + end +end diff --git a/app/services/clusters/agent_tokens/revoke_service.rb b/app/services/clusters/agent_tokens/revoke_service.rb index 5d89b405969..46873fbbc47 100644 --- a/app/services/clusters/agent_tokens/revoke_service.rb +++ b/app/services/clusters/agent_tokens/revoke_service.rb @@ -13,7 +13,7 @@ module Clusters def execute return error_no_permissions unless current_user.can?(:create_cluster, token.agent.project) - if token.update(status: token.class.statuses[:revoked]) + if token.revoke! log_activity_event(token) ServiceResponse.success diff --git a/app/services/clusters/cleanup/project_namespace_service.rb b/app/services/clusters/cleanup/project_namespace_service.rb index f6ac06d0594..3d5a4f85d10 100644 --- a/app/services/clusters/cleanup/project_namespace_service.rb +++ b/app/services/clusters/cleanup/project_namespace_service.rb @@ -40,7 +40,7 @@ module Clusters cluster.kubeclient&.delete_namespace(kubernetes_namespace.namespace) rescue Kubeclient::ResourceNotFoundError # The resources have already been deleted, possibly on a previous attempt that timed out - rescue Gitlab::UrlBlocker::BlockedUrlError + rescue Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError # User gave an invalid cluster from the start, or deleted the endpoint before this job ran end end diff --git a/app/services/clusters/cleanup/service_account_service.rb b/app/services/clusters/cleanup/service_account_service.rb index 0ce4bf9bb9c..0358a5412b3 100644 --- a/app/services/clusters/cleanup/service_account_service.rb +++ b/app/services/clusters/cleanup/service_account_service.rb @@ -22,7 +22,7 @@ module Clusters ) rescue Kubeclient::ResourceNotFoundError # The resources have already been deleted, possibly on a previous attempt that timed out - rescue Gitlab::UrlBlocker::BlockedUrlError + rescue Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError # User gave an invalid cluster from the start, or deleted the endpoint before this job ran rescue Kubeclient::HttpError => e # unauthorized, forbidden: GitLab's access has been revoked diff --git a/app/services/commits/create_service.rb b/app/services/commits/create_service.rb index 89370bd8abb..5fc84e5aad7 100644 --- a/app/services/commits/create_service.rb +++ b/app/services/commits/create_service.rb @@ -40,11 +40,7 @@ module Commits Gitlab::Git::CommandError => ex Gitlab::ErrorTracking.log_exception(ex) - if Feature.enabled?(:errors_utf_8_encoding) - error(Gitlab::EncodingHelper.encode_utf8_no_detect(ex.message)) - else - error(ex.message) - end + error(Gitlab::EncodingHelper.encode_utf8_no_detect(ex.message)) end private diff --git a/app/services/concerns/update_repository_storage_methods.rb b/app/services/concerns/update_repository_storage_methods.rb index f14c79ecd7e..50963cc58b2 100644 --- a/app/services/concerns/update_repository_storage_methods.rb +++ b/app/services/concerns/update_repository_storage_methods.rb @@ -82,7 +82,6 @@ module UpdateRepositoryStorageMethods repository = type.repository_for(type.design? ? container.design_management_repository : container) full_path = repository.full_path raw_repository = repository.raw - checksum = repository.checksum # Initialize a git repository on the target path new_repository = Gitlab::Git::Repository.new( @@ -92,12 +91,7 @@ module UpdateRepositoryStorageMethods full_path ) - new_repository.replicate(raw_repository) - new_checksum = new_repository.checksum - - if checksum != new_checksum - raise Error, s_('UpdateRepositoryStorage|Failed to verify %{type} repository checksum from %{old} to %{new}') % { type: type.name, old: checksum, new: new_checksum } - end + Repositories::ReplicateService.new(raw_repository).execute(new_repository, type.name) end def same_filesystem? diff --git a/app/services/concerns/users/participable_service.rb b/app/services/concerns/users/participable_service.rb index 1a03b444b68..a54c4947b0b 100644 --- a/app/services/concerns/users/participable_service.rb +++ b/app/services/concerns/users/participable_service.rb @@ -34,7 +34,7 @@ module Users def groups return [] unless current_user - current_user.authorized_groups.with_route.sort_by(&:path) + current_user.authorized_groups.with_route.sort_by(&:full_path) end def render_participants_as_hash(participants) diff --git a/app/services/deployments/create_for_job_service.rb b/app/services/deployments/create_for_job_service.rb index e230515ce27..fb07efe8694 100644 --- a/app/services/deployments/create_for_job_service.rb +++ b/app/services/deployments/create_for_job_service.rb @@ -38,8 +38,6 @@ module Deployments return unless deployment.valid? && deployment.environment.persisted? if cluster = deployment.environment.deployment_platform&.cluster # rubocop: disable Lint/AssignmentInCondition - # double write cluster_id until 12.9: https://gitlab.com/gitlab-org/gitlab/issues/202628 - deployment.cluster_id = cluster.id deployment.deployment_cluster = ::DeploymentCluster.new( cluster_id: cluster.id, kubernetes_namespace: cluster.kubernetes_namespace_for(deployment.environment, deployable: job) diff --git a/app/services/deployments/create_service.rb b/app/services/deployments/create_service.rb index ebf2b077bca..8ef9982f41b 100644 --- a/app/services/deployments/create_service.rb +++ b/app/services/deployments/create_service.rb @@ -28,7 +28,6 @@ module Deployments # We use explicit parameters here so we never by accident allow parameters # to be set that one should not be able to set (e.g. the row ID). { - cluster_id: environment.deployment_platform&.cluster_id, project_id: environment.project_id, environment_id: environment.id, ref: params[:ref], diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb index 31da099d078..a2eb4f1f396 100644 --- a/app/services/git/branch_hooks_service.rb +++ b/app/services/git/branch_hooks_service.rb @@ -156,7 +156,7 @@ module Git return if branch_to_sync.nil? && commits_to_sync.empty? - if commits_to_sync.any? && Feature.enabled?(:batch_delay_jira_branch_sync_worker, project) + if commits_to_sync.any? commits_to_sync.each_slice(JIRA_SYNC_BATCH_SIZE).with_index do |commits, i| JiraConnect::SyncBranchWorker.perform_in( JIRA_SYNC_BATCH_DELAY * i, diff --git a/app/services/import/bitbucket_server_service.rb b/app/services/import/bitbucket_server_service.rb index 3d961780889..e628e88eaa9 100644 --- a/app/services/import/bitbucket_server_service.rb +++ b/app/services/import/bitbucket_server_service.rb @@ -42,7 +42,8 @@ module Import project_name, target_namespace, current_user, - credentials + credentials, + timeout_strategy ).execute end @@ -74,6 +75,10 @@ module Import @url ||= params[:bitbucket_server_url] end + def timeout_strategy + @timeout_strategy ||= params[:timeout_strategy] || ProjectImportData::PESSIMISTIC_TIMEOUT + end + def allow_local_requests? Gitlab::CurrentSettings.allow_local_requests_from_web_hooks_and_services? end diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb index 73e0c229a9c..86c62145a87 100644 --- a/app/services/import/github_service.rb +++ b/app/services/import/github_service.rb @@ -138,6 +138,7 @@ module Import Gitlab::GithubImport::Settings .new(project) .write( + timeout_strategy: params[:timeout_strategy] || ProjectImportData::PESSIMISTIC_TIMEOUT, optional_stages: params[:optional_stages], additional_access_tokens: access_params[:additional_access_tokens] ) diff --git a/app/services/import/validate_remote_git_endpoint_service.rb b/app/services/import/validate_remote_git_endpoint_service.rb index a994072c4aa..2177238fddf 100644 --- a/app/services/import/validate_remote_git_endpoint_service.rb +++ b/app/services/import/validate_remote_git_endpoint_service.rb @@ -13,6 +13,8 @@ module Import GIT_PROTOCOL_PKT_LEN = 4 GIT_MINIMUM_RESPONSE_LENGTH = GIT_PROTOCOL_PKT_LEN + GIT_EXPECTED_FIRST_PACKET_LINE.length EXPECTED_CONTENT_TYPE = "application/x-#{GIT_SERVICE_NAME}-advertisement" + INVALID_BODY_MESSAGE = 'Not a git repository: Invalid response body' + INVALID_CONTENT_TYPE_MESSAGE = 'Not a git repository: Invalid content-type' def initialize(params) @params = params @@ -30,32 +32,35 @@ module Import uri.fragment = nil url = Gitlab::Utils.append_path(uri.to_s, "/info/refs?service=#{GIT_SERVICE_NAME}") - response_body = '' - result = nil - Gitlab::HTTP.try_get(url, stream_body: true, follow_redirects: false, basic_auth: auth) do |fragment| - response_body += fragment - next if response_body.length < GIT_MINIMUM_RESPONSE_LENGTH - - result = if status_code_is_valid(fragment) && content_type_is_valid(fragment) && response_body_is_valid(response_body) - :success - else - :error - end - - # We are interested only in the first chunks of the response - # So we're using stream_body: true and breaking when receive enough body - break - end + response, response_body = http_get_and_extract_first_chunks(url) - if result == :success - ServiceResponse.success - else - ServiceResponse.error(message: "#{uri} is not a valid HTTP Git repository") - end + validate(uri, response, response_body) + rescue *Gitlab::HTTP::HTTP_ERRORS => err + error_result("HTTP #{err.class.name.underscore} error: #{err.message}") + rescue StandardError => err + ServiceResponse.error( + message: "Internal #{err.class.name.underscore} error: #{err.message}", + reason: 500 + ) end private + def http_get_and_extract_first_chunks(url) + # We are interested only in the first chunks of the response + # So we're using stream_body: true and breaking when receive enough body + response = nil + response_body = '' + + Gitlab::HTTP.get(url, stream_body: true, follow_redirects: false, basic_auth: auth) do |response_chunk| + response = response_chunk + response_body += response_chunk + break if GIT_MINIMUM_RESPONSE_LENGTH <= response_body.length + end + + [response, response_body] + end + def auth unless @params[:user].to_s.blank? { @@ -65,16 +70,38 @@ module Import end end - def status_code_is_valid(fragment) - fragment.http_response.code == '200' + def validate(uri, response, response_body) + return status_code_error(uri, response) unless status_code_is_valid?(response) + return error_result(INVALID_CONTENT_TYPE_MESSAGE) unless content_type_is_valid?(response) + return error_result(INVALID_BODY_MESSAGE) unless response_body_is_valid?(response_body) + + ServiceResponse.success + end + + def status_code_error(uri, response) + http_code = response.http_response.code.to_i + message = response.http_response.message || Rack::Utils::HTTP_STATUS_CODES[http_code] + + error_result( + "#{uri} endpoint error: #{http_code}#{message.presence&.prepend(' ')}", + http_code + ) + end + + def error_result(message, reason = nil) + ServiceResponse.error(message: message, reason: reason) + end + + def status_code_is_valid?(response) + response.http_response.code == '200' end - def content_type_is_valid(fragment) - fragment.http_response['content-type'] == EXPECTED_CONTENT_TYPE + def content_type_is_valid?(response) + response.http_response['content-type'] == EXPECTED_CONTENT_TYPE end - def response_body_is_valid(response_body) - response_body.match?(GIT_BODY_MESSAGE_REGEXP) + def response_body_is_valid?(response_body) + response_body.length <= GIT_MINIMUM_RESPONSE_LENGTH && response_body.match?(GIT_BODY_MESSAGE_REGEXP) end end end diff --git a/app/services/issuable/clone/base_service.rb b/app/services/issuable/clone/base_service.rb index a4e815e70fc..d7fdd235db1 100644 --- a/app/services/issuable/clone/base_service.rb +++ b/app/services/issuable/clone/base_service.rb @@ -93,5 +93,3 @@ module Issuable end end end - -Issuable::Clone::BaseService.prepend_mod_with('Issuable::Clone::BaseService') diff --git a/app/services/issuable_links/create_service.rb b/app/services/issuable_links/create_service.rb index 761ba3f74aa..c855a58522c 100644 --- a/app/services/issuable_links/create_service.rb +++ b/app/services/issuable_links/create_service.rb @@ -2,13 +2,14 @@ module IssuableLinks class CreateService < BaseService - attr_reader :issuable, :current_user, :params + attr_reader :issuable, :current_user, :params, :new_links def initialize(issuable, user, params) @issuable = issuable @current_user = user @params = params.dup @errors = [] + @new_links = [] end def execute @@ -45,6 +46,7 @@ module IssuableLinks set_link_type(link) if link.changed? && link.save + new_links << link create_notes(link) end diff --git a/app/services/issue_links/create_service.rb b/app/services/issue_links/create_service.rb index db05920678e..3523e945d37 100644 --- a/app/services/issue_links/create_service.rb +++ b/app/services/issue_links/create_service.rb @@ -9,7 +9,7 @@ module IssueLinks end def previous_related_issuables - @related_issues ||= issuable.related_issues(current_user).to_a + @related_issues ||= issuable.related_issues(authorize: false).to_a end private diff --git a/app/services/issues/set_crm_contacts_service.rb b/app/services/issues/set_crm_contacts_service.rb index 5836097f1fd..c2ed7c554be 100644 --- a/app/services/issues/set_crm_contacts_service.rb +++ b/app/services/issues/set_crm_contacts_service.rb @@ -13,7 +13,7 @@ module Issues return error_invalid_params unless valid_params? @existing_ids = issue.customer_relations_contact_ids - determine_changes if params[:replace_ids].present? + determine_changes if set_present? return error_too_many if too_many? @added_count = 0 @@ -108,7 +108,7 @@ module Issues end def set_present? - params[:replace_ids].present? + !params[:replace_ids].nil? end def add_or_remove_present? diff --git a/app/services/jira_connect/sync_service.rb b/app/services/jira_connect/sync_service.rb index 497c282072d..5d9292a6967 100644 --- a/app/services/jira_connect/sync_service.rb +++ b/app/services/jira_connect/sync_service.rb @@ -9,6 +9,8 @@ module JiraConnect # Parameters: see Atlassian::JiraConnect::Client#send_info # Includes: update_sequence_id, commits, branches, merge_requests, pipelines def execute(**args) + preload_reviewers_for_merge_requests(args[:merge_requests]) if args.key?(:merge_requests) + JiraConnectInstallation.for_project(project).flat_map do |installation| client = Atlassian::JiraConnect::Client.new(installation.base_url, installation.shared_secret) @@ -43,5 +45,11 @@ module JiraConnect def logger Gitlab::IntegrationsLogger end + + def preload_reviewers_for_merge_requests(merge_requests) + ActiveRecord::Associations::Preloader.new( + records: merge_requests, associations: [:approvals, { merge_request_reviewers: :reviewer }] + ).call + end end end diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index aba075c3644..9cedc7ee3a5 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -16,7 +16,6 @@ module Members @errors = [] @invites = invites_from_params @source = params[:source] - @tasks_to_be_done_members = [] end def execute @@ -31,13 +30,13 @@ module Members validate_invitable! add_members - create_tasks_to_be_done enqueue_onboarding_progress_action publish_event! result rescue BlankInvitesError, TooManyInvitesError, MembershipLockedError => e + Gitlab::ErrorTracking.log_exception(e, class: self.class.to_s, user_id: current_user.id) error(e.message) end @@ -47,8 +46,7 @@ module Members private - attr_reader :source, :errors, :invites, :member_created_namespace_id, :members, - :tasks_to_be_done_members, :member_created_member_task_id + attr_reader :source, :errors, :invites, :member_created_namespace_id, :members def adding_at_least_one_owner params[:access_level] == Gitlab::Access::OWNER @@ -88,9 +86,7 @@ module Members invites, params[:access_level], expires_at: params[:expires_at], - current_user: current_user, - tasks_to_be_done: params[:tasks_to_be_done], - tasks_project_id: params[:tasks_project_id] + current_user: current_user ) members.each { |member| process_result(member) } @@ -123,7 +119,6 @@ module Members def after_execute(member:) super - build_tasks_to_be_done_members(member) track_invite_source(member) end @@ -146,30 +141,6 @@ module Members member.invite? ? 'net_new_user' : 'existing_user' end - def build_tasks_to_be_done_members(member) - return unless tasks_to_be_done?(member) - - @tasks_to_be_done_members << member - # We can take the first `member_task` here, since all tasks will have the same attributes needed - # for the `TasksToBeDone::CreateWorker`, ie. `project` and `tasks_to_be_done`. - @member_created_member_task_id ||= member.member_task.id - end - - def tasks_to_be_done?(member) - return false if params[:tasks_to_be_done].blank? || params[:tasks_project_id].blank? - - # Only create task issues for existing users. Tasks for new users are created when they signup. - member.member_task&.valid? && member.user.present? - end - - def create_tasks_to_be_done - return unless member_created_member_task_id # signal if there is any work to be done here - - TasksToBeDone::CreateWorker.perform_async(member_created_member_task_id, - current_user.id, - tasks_to_be_done_members.map(&:user_id)) - end - def user_limit limit = params.fetch(:limit, DEFAULT_INVITE_LIMIT) diff --git a/app/services/members/creator_service.rb b/app/services/members/creator_service.rb index cc18aae7446..22d8b30db18 100644 --- a/app/services/members/creator_service.rb +++ b/app/services/members/creator_service.rb @@ -53,8 +53,7 @@ module Members common_arguments = { source: source, access_level: access_level, - existing_members: existing_members, - tasks_to_be_done: args[:tasks_to_be_done] || [] + existing_members: existing_members }.merge(parsed_args(args)) members = emails.map do |email| @@ -81,7 +80,6 @@ module Members { current_user: args[:current_user], expires_at: args[:expires_at], - tasks_project_id: args[:tasks_project_id], ldap: args[:ldap] } end @@ -212,22 +210,7 @@ module Members end def after_commit_tasks - create_member_task - end - - def create_member_task - return unless member.persisted? - return if member_task_attributes.value?(nil) - return if member.member_task.present? - - member.create_member_task(member_task_attributes) - end - - def member_task_attributes - { - tasks_to_be_done: args[:tasks_to_be_done], - project_id: args[:tasks_project_id] - } + # hook for overriding in other uses end def approve_request diff --git a/app/services/merge_requests/approval_service.rb b/app/services/merge_requests/approval_service.rb index dbe5567cbc5..f9857cdad39 100644 --- a/app/services/merge_requests/approval_service.rb +++ b/app/services/merge_requests/approval_service.rb @@ -7,7 +7,7 @@ module MergeRequests approval = merge_request.approvals.new( user: current_user, - patch_id_sha: fetch_patch_id_sha(merge_request) + patch_id_sha: merge_request.current_patch_id_sha ) return success unless save_approval(approval) @@ -36,17 +36,6 @@ module MergeRequests private - def fetch_patch_id_sha(merge_request) - diff_refs = merge_request.diff_refs - base_sha = diff_refs&.base_sha - head_sha = diff_refs&.head_sha - - return unless base_sha && head_sha - return if base_sha == head_sha - - merge_request.project.repository.get_patch_id(base_sha, head_sha) - end - def eligible_for_approval?(merge_request) merge_request.eligible_for_approval_by?(current_user) end diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index 29aba3c8679..89e5920a4fb 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -181,3 +181,5 @@ module MergeRequests end end end + +MergeRequests::MergeService.prepend_mod diff --git a/app/services/merge_requests/mergeability/check_base_service.rb b/app/services/merge_requests/mergeability/check_base_service.rb index e614a7c27fe..e1c4d751296 100644 --- a/app/services/merge_requests/mergeability/check_base_service.rb +++ b/app/services/merge_requests/mergeability/check_base_service.rb @@ -9,6 +9,10 @@ module MergeRequests @params = params end + def self.identifier + failure_reason + end + def skip? raise NotImplementedError end @@ -24,12 +28,22 @@ module MergeRequests private + def failure_reason + self.class.failure_reason + end + def success(**args) - Gitlab::MergeRequests::Mergeability::CheckResult.success(payload: args) + Gitlab::MergeRequests::Mergeability::CheckResult + .success(payload: default_payload(args)) end def failure(**args) - Gitlab::MergeRequests::Mergeability::CheckResult.failed(payload: args) + Gitlab::MergeRequests::Mergeability::CheckResult + .failed(payload: default_payload(args)) + end + + def default_payload(args) + args.merge(identifier: self.class.identifier) end end end diff --git a/app/services/merge_requests/mergeability/check_broken_status_service.rb b/app/services/merge_requests/mergeability/check_broken_status_service.rb index 6fe4eb4a57f..25293c53bb5 100644 --- a/app/services/merge_requests/mergeability/check_broken_status_service.rb +++ b/app/services/merge_requests/mergeability/check_broken_status_service.rb @@ -2,6 +2,10 @@ module MergeRequests module Mergeability class CheckBrokenStatusService < CheckBaseService + def self.failure_reason + :broken_status + end + def execute if merge_request.broken? failure(reason: failure_reason) @@ -17,12 +21,6 @@ module MergeRequests def cacheable? false end - - private - - def failure_reason - :broken_status - end end end end diff --git a/app/services/merge_requests/mergeability/check_ci_status_service.rb b/app/services/merge_requests/mergeability/check_ci_status_service.rb index 9e09b513c57..f7fa3259d97 100644 --- a/app/services/merge_requests/mergeability/check_ci_status_service.rb +++ b/app/services/merge_requests/mergeability/check_ci_status_service.rb @@ -2,6 +2,10 @@ module MergeRequests module Mergeability class CheckCiStatusService < CheckBaseService + def self.failure_reason + :ci_must_pass + end + def execute if merge_request.mergeable_ci_state? success @@ -17,12 +21,6 @@ module MergeRequests def cacheable? false end - - private - - def failure_reason - :ci_must_pass - end end end end diff --git a/app/services/merge_requests/mergeability/check_conflict_status_service.rb b/app/services/merge_requests/mergeability/check_conflict_status_service.rb new file mode 100644 index 00000000000..2bc253322c9 --- /dev/null +++ b/app/services/merge_requests/mergeability/check_conflict_status_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module MergeRequests + module Mergeability + class CheckConflictStatusService < CheckBaseService + def self.failure_reason + :conflict + end + + def execute + if merge_request.can_be_merged? + success + else + failure(reason: failure_reason) + end + end + + def skip? + false + end + + def cacheable? + false + end + end + end +end diff --git a/app/services/merge_requests/mergeability/check_discussions_status_service.rb b/app/services/merge_requests/mergeability/check_discussions_status_service.rb index 3421d96e8ae..34db5f8a944 100644 --- a/app/services/merge_requests/mergeability/check_discussions_status_service.rb +++ b/app/services/merge_requests/mergeability/check_discussions_status_service.rb @@ -2,6 +2,10 @@ module MergeRequests module Mergeability class CheckDiscussionsStatusService < CheckBaseService + def self.failure_reason + :discussions_not_resolved + end + def execute if merge_request.mergeable_discussions_state? success @@ -17,12 +21,6 @@ module MergeRequests def cacheable? false end - - private - - def failure_reason - :discussions_not_resolved - end end end end diff --git a/app/services/merge_requests/mergeability/check_draft_status_service.rb b/app/services/merge_requests/mergeability/check_draft_status_service.rb index a1524317155..85b67fdc629 100644 --- a/app/services/merge_requests/mergeability/check_draft_status_service.rb +++ b/app/services/merge_requests/mergeability/check_draft_status_service.rb @@ -3,6 +3,10 @@ module MergeRequests module Mergeability class CheckDraftStatusService < CheckBaseService + def self.failure_reason + :draft_status + end + def execute if merge_request.draft? failure(reason: failure_reason) @@ -12,18 +16,12 @@ module MergeRequests end def skip? - false + params[:skip_draft_check].present? end def cacheable? false end - - private - - def failure_reason - :draft_status - end end end end diff --git a/app/services/merge_requests/mergeability/check_open_status_service.rb b/app/services/merge_requests/mergeability/check_open_status_service.rb index 29f3d0d3ccb..f5b70f18394 100644 --- a/app/services/merge_requests/mergeability/check_open_status_service.rb +++ b/app/services/merge_requests/mergeability/check_open_status_service.rb @@ -3,6 +3,10 @@ module MergeRequests module Mergeability class CheckOpenStatusService < CheckBaseService + def self.failure_reason + :not_open + end + def execute if merge_request.open? success @@ -18,12 +22,6 @@ module MergeRequests def cacheable? false end - - private - - def failure_reason - :not_open - end end end end diff --git a/app/services/merge_requests/mergeability/check_rebase_status_service.rb b/app/services/merge_requests/mergeability/check_rebase_status_service.rb new file mode 100644 index 00000000000..2163fec8bd6 --- /dev/null +++ b/app/services/merge_requests/mergeability/check_rebase_status_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module MergeRequests + module Mergeability + class CheckRebaseStatusService < CheckBaseService + def self.failure_reason + :need_rebase + end + + def execute + if merge_request.should_be_rebased? + failure(reason: failure_reason) + else + success + end + end + + def skip? + params[:skip_rebase_check].present? + end + + def cacheable? + false + end + end + end +end diff --git a/app/services/merge_requests/mergeability/detailed_merge_status_service.rb b/app/services/merge_requests/mergeability/detailed_merge_status_service.rb index 987d6ce8e9f..86c8122604c 100644 --- a/app/services/merge_requests/mergeability/detailed_merge_status_service.rb +++ b/app/services/merge_requests/mergeability/detailed_merge_status_service.rb @@ -24,7 +24,7 @@ module MergeRequests ci_check_failure_reason end else - check_results.failure_reason + check_results.payload[:failure_reason] end end @@ -46,7 +46,11 @@ module MergeRequests def check_results strong_memoize(:check_results) do - merge_request.execute_merge_checks(params: { skip_ci_check: true }) + merge_request + .execute_merge_checks( + MergeRequest.mergeable_state_checks, + params: { skip_ci_check: true } + ) end end diff --git a/app/services/merge_requests/mergeability/run_checks_service.rb b/app/services/merge_requests/mergeability/run_checks_service.rb index 740a6feac2c..5150c03d0a3 100644 --- a/app/services/merge_requests/mergeability/run_checks_service.rb +++ b/app/services/merge_requests/mergeability/run_checks_service.rb @@ -9,8 +9,8 @@ module MergeRequests @params = params end - def execute - @results = merge_request.mergeability_checks.each_with_object([]) do |check_class, result_hash| + def execute(checks, execute_all: false) + @results = checks.each_with_object([]) do |check_class, result_hash| check = check_class.new(merge_request: merge_request, params: params) next if check.skip? @@ -21,24 +21,20 @@ module MergeRequests result_hash << check_result - break result_hash if check_result.failed? + break result_hash if check_result.failed? && !execute_all end logger.commit - self - end - - def success? - raise 'Execute needs to be called before' if results.nil? - - results.all?(&:success?) - end - - def failure_reason - raise 'Execute needs to be called before' if results.nil? + return ServiceResponse.success(payload: { results: results }) if all_results_success? - results.find(&:failed?)&.payload&.fetch(:reason)&.to_sym + ServiceResponse.error( + message: 'Checks failed.', + payload: { + results: results, + failure_reason: failure_reason + } + ) end private @@ -67,6 +63,14 @@ module MergeRequests MergeRequests::Mergeability::Logger.new(merge_request: merge_request) end end + + def all_results_success? + results.all?(&:success?) + end + + def failure_reason + results.find(&:failed?)&.payload&.fetch(:reason)&.to_sym + end end end end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index c435048e343..37a829e3014 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -185,6 +185,7 @@ module MergeRequests # email template itself, see `change_in_merge_request_draft_status_email` template. notify_draft_status_changed(merge_request) trigger_merge_request_status_updated(merge_request) + publish_draft_change_event(merge_request) if Feature.enabled?(:additional_merge_when_checks_ready, project) end if !old_title_draft && new_title_draft @@ -196,6 +197,14 @@ module MergeRequests end end + def publish_draft_change_event(merge_request) + Gitlab::EventStore.publish( + MergeRequests::DraftStateChangeEvent.new( + data: { current_user_id: current_user.id, merge_request_id: merge_request.id } + ) + ) + end + def notify_draft_status_changed(merge_request) notification_service.async.change_in_merge_request_draft_status( merge_request, diff --git a/app/services/ml/find_or_create_model_version_service.rb b/app/services/ml/find_or_create_model_version_service.rb index 1316b2546b9..f4d3f3e72d3 100644 --- a/app/services/ml/find_or_create_model_version_service.rb +++ b/app/services/ml/find_or_create_model_version_service.rb @@ -11,7 +11,6 @@ module Ml def execute model = Ml::FindOrCreateModelService.new(project, name).execute - Ml::ModelVersion.find_or_create!(model, version, package) end diff --git a/app/services/notes/quick_actions_service.rb b/app/services/notes/quick_actions_service.rb index cba7398ebc0..1b852710677 100644 --- a/app/services/notes/quick_actions_service.rb +++ b/app/services/notes/quick_actions_service.rb @@ -61,7 +61,7 @@ module Notes service_errors = if service_response.respond_to?(:errors) service_response.errors.full_messages elsif service_response.respond_to?(:[]) && service_response[:status] == :error - service_response[:message] + Array.wrap(service_response[:message]) end service_errors.blank? ? ServiceResponse.success : ServiceResponse.error(message: service_errors) diff --git a/app/services/packages/create_dependency_service.rb b/app/services/packages/create_dependency_service.rb index 10a86e44cb0..51f8a514c55 100644 --- a/app/services/packages/create_dependency_service.rb +++ b/app/services/packages/create_dependency_service.rb @@ -59,7 +59,7 @@ module Packages # The bulk_insert statement above do not dirty the query cache. To make # sure that the results are fresh from the database and not from a stalled # and potentially wrong cache, this query has to be done with the query - # chache disabled. + # cache disabled. Packages::Dependency.ids_for_package_names_and_version_patterns(names_and_version_patterns) end end diff --git a/app/services/packages/maven/find_or_create_package_service.rb b/app/services/packages/maven/find_or_create_package_service.rb index ac0c77391d7..2ff3ebc3bb2 100644 --- a/app/services/packages/maven/find_or_create_package_service.rb +++ b/app/services/packages/maven/find_or_create_package_service.rb @@ -10,7 +10,7 @@ module Packages package = ::Packages::Maven::PackageFinder.new(current_user, project, path: path) - .execute + .execute&.last unless Namespace::PackageSetting.duplicates_allowed?(package) return ServiceResponse.error(message: 'Duplicate package is not allowed') if target_package_is_duplicate?(package) diff --git a/app/services/packages/npm/create_package_service.rb b/app/services/packages/npm/create_package_service.rb index f6f2dbb8415..d599cecc8da 100644 --- a/app/services/packages/npm/create_package_service.rb +++ b/app/services/packages/npm/create_package_service.rb @@ -5,7 +5,7 @@ module Packages include Gitlab::Utils::StrongMemoize include ExclusiveLeaseGuard - PACKAGE_JSON_NOT_ALLOWED_FIELDS = %w[readme readmeFilename licenseText].freeze + PACKAGE_JSON_NOT_ALLOWED_FIELDS = %w[readme readmeFilename licenseText contributors exports].freeze DEFAULT_LEASE_TIMEOUT = 1.hour.to_i def execute diff --git a/app/services/packages/nuget/extract_metadata_file_service.rb b/app/services/packages/nuget/extract_metadata_file_service.rb index cc040a45016..fd4f9b5d1c1 100644 --- a/app/services/packages/nuget/extract_metadata_file_service.rb +++ b/app/services/packages/nuget/extract_metadata_file_service.rb @@ -7,48 +7,30 @@ module Packages MAX_FILE_SIZE = 4.megabytes.freeze - def initialize(package_file) - @package_file = package_file + def initialize(package_zip_file) + @package_zip_file = package_zip_file end def execute - raise ExtractionError, 'invalid package file' unless valid_package_file? - ServiceResponse.success(payload: nuspec_file_content) end private - attr_reader :package_file - - def valid_package_file? - package_file && - package_file.package&.nuget? && - package_file.file.size > 0 # rubocop:disable Style/ZeroLengthPredicate - end + attr_reader :package_zip_file def nuspec_file_content - with_zip_file do |zip_file| - entry = zip_file.glob('*.nuspec').first - - raise ExtractionError, 'nuspec file not found' unless entry - raise ExtractionError, 'nuspec file too big' if MAX_FILE_SIZE < entry.size - - Tempfile.open("nuget_extraction_package_file_#{package_file.id}") do |file| - entry.extract(file.path) { true } # allow #extract to overwrite the file - file.unlink - file.read - end - rescue Zip::EntrySizeError => e - raise ExtractionError, "nuspec file has the wrong entry size: #{e.message}" - end - end + entry = package_zip_file.glob('*.nuspec').first + + raise ExtractionError, 'nuspec file not found' unless entry + raise ExtractionError, 'nuspec file too big' if MAX_FILE_SIZE < entry.size - def with_zip_file - package_file.file.use_open_file do |open_file| - zip_file = Zip::File.new(open_file, false, true) # rubocop:disable Performance/Rubyzip - yield(zip_file) + Tempfile.create('nuget_extraction_package_file') do |file| + entry.extract(file.path) { true } # allow #extract to overwrite the file + file.read end + rescue Zip::EntrySizeError => e + raise ExtractionError, "nuspec file has the wrong entry size: #{e.message}" end end end diff --git a/app/services/packages/nuget/metadata_extraction_service.rb b/app/services/packages/nuget/metadata_extraction_service.rb index 2c758a5ec20..53189063c85 100644 --- a/app/services/packages/nuget/metadata_extraction_service.rb +++ b/app/services/packages/nuget/metadata_extraction_service.rb @@ -23,10 +23,9 @@ module Packages end def nuspec_file_content - ExtractMetadataFileService + ProcessPackageFileService .new(package_file) - .execute - .payload + .execute[:nuspec_file_content] end end end diff --git a/app/services/packages/nuget/odata_package_entry_service.rb b/app/services/packages/nuget/odata_package_entry_service.rb index 0cdcc38de16..679b01d6c48 100644 --- a/app/services/packages/nuget/odata_package_entry_service.rb +++ b/app/services/packages/nuget/odata_package_entry_service.rb @@ -5,9 +5,6 @@ module Packages class OdataPackageEntryService include API::Helpers::RelatedResourcesHelpers - SEMVER_LATEST_VERSION_PLACEHOLDER = '0.0.0-latest-version' - LATEST_VERSION_FOR_V2_DOWNLOAD_ENDPOINT = 'latest' - def initialize(project, params) @project = project @params = params @@ -29,42 +26,40 @@ module Packages <title type='text'>#{params[:package_name]}</title> <content type='application/zip' src="#{download_url}"/> <m:properties> - <d:Version>#{package_version}</d:Version> + <d:Version>#{params[:package_version]}</d:Version> </m:properties> </entry> XML end - def package_version - params[:package_version] || SEMVER_LATEST_VERSION_PLACEHOLDER - end - def id_url expose_url "#{api_v4_projects_packages_nuget_v2_path(id: project.id)}" \ - "/Packages(Id='#{params[:package_name]}',Version='#{package_version}')" + "/Packages(Id='#{params[:package_name]}',Version='#{params[:package_version]}')" end - # TODO: use path helper when download endpoint is merged def download_url - expose_url "#{api_v4_projects_packages_nuget_v2_path(id: project.id)}" \ - "/download/#{params[:package_name]}/#{download_url_package_version}" - end - - def download_url_package_version - if latest_version? - LATEST_VERSION_FOR_V2_DOWNLOAD_ENDPOINT + if params[:package_version].present? + expose_url api_v4_projects_packages_nuget_download_package_name_package_version_package_filename_path( + { + id: project.id, + package_name: params[:package_name], + package_version: params[:package_version], + package_filename: file_name + }, + true + ) else - params[:package_version] + xml_base end end - def latest_version? - params[:package_version].nil? || params[:package_version] == SEMVER_LATEST_VERSION_PLACEHOLDER - end - def xml_base expose_url api_v4_projects_packages_nuget_v2_path(id: project.id) end + + def file_name + "#{params[:package_name]}.#{params[:package_version]}.nupkg" + end end end end diff --git a/app/services/packages/nuget/process_package_file_service.rb b/app/services/packages/nuget/process_package_file_service.rb new file mode 100644 index 00000000000..fa7a84ee3d6 --- /dev/null +++ b/app/services/packages/nuget/process_package_file_service.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class ProcessPackageFileService + ExtractionError = Class.new(StandardError) + NUGET_SYMBOL_FILE_EXTENSION = '.snupkg' + + def initialize(package_file) + @package_file = package_file + end + + def execute + raise ExtractionError, 'invalid package file' unless valid_package_file? + + nuspec_content = nil + + with_zip_file do |zip_file| + nuspec_content = nuspec_file_content(zip_file) + create_symbol_files(zip_file) if symbol_package_file? + end + + ServiceResponse.success(payload: { nuspec_file_content: nuspec_content }) + end + + private + + attr_reader :package_file + + def valid_package_file? + package_file && + package_file.package&.nuget? && + package_file.file.size > 0 # rubocop:disable Style/ZeroLengthPredicate + end + + def with_zip_file(&block) + package_file.file.use_open_file(unlink_early: false) do |open_file| + Zip::File.open(open_file.file_path, &block) # rubocop: disable Performance/Rubyzip + end + end + + def nuspec_file_content(zip_file) + ::Packages::Nuget::ExtractMetadataFileService + .new(zip_file) + .execute + .payload + end + + def create_symbol_files(zip_file) + ::Packages::Nuget::Symbols::CreateSymbolFilesService + .new(package_file.package, zip_file) + .execute + end + + def symbol_package_file? + package_file.file_name.end_with?(NUGET_SYMBOL_FILE_EXTENSION) + end + end + end +end diff --git a/app/services/packages/nuget/symbols/create_symbol_files_service.rb b/app/services/packages/nuget/symbols/create_symbol_files_service.rb new file mode 100644 index 00000000000..03e14ba00e1 --- /dev/null +++ b/app/services/packages/nuget/symbols/create_symbol_files_service.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Packages + module Nuget + module Symbols + class CreateSymbolFilesService + ExtractionError = Class.new(StandardError) + SYMBOL_ENTRIES_LIMIT = 100 + CONTENT_TYPE = 'application/octet-stream' + + def initialize(package, package_zip_file) + @package = package + @symbol_entries = package_zip_file.glob('**/*.pdb') + end + + def execute + return if symbol_entries.empty? + + process_symbol_entries + rescue ExtractionError => e + Gitlab::ErrorTracking.log_exception(e, class: self.class.name, package_id: package.id) + end + + private + + attr_reader :package, :symbol_entries + + def process_symbol_entries + Tempfile.create('nuget_extraction_symbol_file') do |tmp_file| + symbol_entries.each_with_index do |entry, index| + raise ExtractionError, 'too many symbol entries' if index >= SYMBOL_ENTRIES_LIMIT + + entry.extract(tmp_file.path) { true } + File.open(tmp_file.path) do |file| + create_symbol(entry.name, file) + end + end + end + rescue Zip::EntrySizeError => e + raise ExtractionError, "symbol file has the wrong entry size: #{e.message}" + rescue Zip::EntryNameError => e + raise ExtractionError, "symbol file has the wrong entry name: #{e.message}" + end + + def create_symbol(path, file) + signature = extract_signature(file.read(1.kilobyte)) + return if signature.blank? + + ::Packages::Nuget::Symbol.create!( + package: package, + file: { tempfile: file, filename: path.downcase, content_type: CONTENT_TYPE }, + file_path: path, + signature: signature, + size: file.size + ) + rescue StandardError => e + Gitlab::ErrorTracking.log_exception(e, class: self.class.name, package_id: package.id) + end + + def extract_signature(content_fragment) + ExtractSymbolSignatureService + .new(content_fragment) + .execute + .payload + end + end + end + end +end diff --git a/app/services/packages/nuget/symbols/extract_symbol_signature_service.rb b/app/services/packages/nuget/symbols/extract_symbol_signature_service.rb new file mode 100644 index 00000000000..c2ccdb517b5 --- /dev/null +++ b/app/services/packages/nuget/symbols/extract_symbol_signature_service.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Packages + module Nuget + module Symbols + class ExtractSymbolSignatureService + include Gitlab::Utils::StrongMemoize + + # More information about the GUID format can be found here: + # https://github.com/dotnet/symstore/blob/main/docs/specs/SSQP_Key_Conventions.md#key-formatting-basic-rules + GUID_START_INDEX = 7 + GUID_END_INDEX = 22 + GUID_PARTS_LENGTHS = [4, 2, 2, 8].freeze + GUID_AGE_PART = 'FFFFFFFF' + TWO_CHARACTER_HEX_REGEX = /\h{2}/ + + # The extraction of the signature in this service is based on the following documentation: + # https://github.com/dotnet/symstore/blob/main/docs/specs/SSQP_Key_Conventions.md#portable-pdb-signature + + def initialize(symbol_content) + @symbol_content = symbol_content + end + + def execute + return error_response unless signature + + ServiceResponse.success(payload: signature) + end + + private + + attr_reader :symbol_content + + def signature + # Find the index of the first occurrence of 'Blob' + guid_index = symbol_content.index('Blob') + return if guid_index.nil? + + # Extract the binary GUID from the symbol content + guid = symbol_content[(guid_index + GUID_START_INDEX)..(guid_index + GUID_END_INDEX)] + return if guid.nil? + + # Convert the GUID into an array of two-character hex strings + guid = guid.unpack('H*').flat_map { |el| el.scan(TWO_CHARACTER_HEX_REGEX) } + + # Reorder the GUID parts based on arbitrary lengths + guid = GUID_PARTS_LENGTHS.map { |length| guid.shift(length) } + + # Concatenate the parts of the GUID back together + result = guid.first(3).map(&:reverse) + result << guid.last + result = result.join + result << GUID_AGE_PART + end + strong_memoize_attr :signature + + def error_response + ServiceResponse.error(message: 'Could not find the signature in the symbol file') + end + end + end + end +end diff --git a/app/services/packages/nuget/update_package_from_metadata_service.rb b/app/services/packages/nuget/update_package_from_metadata_service.rb index 258f8c8f6aa..4cec4ed2fae 100644 --- a/app/services/packages/nuget/update_package_from_metadata_service.rb +++ b/app/services/packages/nuget/update_package_from_metadata_service.rb @@ -54,13 +54,16 @@ module Packages update_linked_package end - update_package(target_package) + build_infos = package_to_destroy&.build_infos || [] + + update_package(target_package, build_infos) + update_symbol_files(target_package, package_to_destroy) if symbol_package? ::Packages::UpdatePackageFileService.new(@package_file, package_id: target_package.id, file_name: package_filename) .execute package_to_destroy&.destroy! end - def update_package(package) + def update_package(package, build_infos) return if symbol_package? ::Packages::Nuget::SyncMetadatumService @@ -71,28 +74,21 @@ module Packages .new(package, package_tags) .execute + package.build_infos << build_infos if build_infos.any? rescue StandardError => e raise InvalidMetadataError, e.message end + def update_symbol_files(package, package_to_destroy) + package_to_destroy.nuget_symbols.update_all(package_id: package.id) + end + def valid_metadata? fields = [package_name, package_version, package_description] fields << package_authors unless symbol_package? fields.all?(&:present?) end - def link_to_existing_package - package_to_destroy = @package_file.package - # Updating package_id updates the path where the file is stored. - # We must pass the file again so that CarrierWave can handle the update - @package_file.update!( - package_id: existing_package.id, - file: @package_file.file - ) - package_to_destroy.destroy! - existing_package - end - def update_linked_package @package_file.package.update!( name: package_name, @@ -106,12 +102,15 @@ module Packages end def existing_package - @package_file.project.packages - .nuget - .with_name(package_name) - .with_version(package_version) - .not_pending_destruction - .first + ::Packages::Nuget::PackageFinder + .new( + nil, + @package_file.project, + package_name: package_name, + package_version: package_version + ) + .execute + .first end strong_memoize_attr :existing_package diff --git a/app/services/packages/protection/create_rule_service.rb b/app/services/packages/protection/create_rule_service.rb new file mode 100644 index 00000000000..e69eb8faf60 --- /dev/null +++ b/app/services/packages/protection/create_rule_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Packages + module Protection + class CreateRuleService < BaseProjectService + ALLOWED_ATTRIBUTES = %i[ + package_name_pattern + package_type + push_protected_up_to_access_level + ].freeze + + def execute + unless can?(current_user, :admin_package, project) + error_message = _('Unauthorized to create a package protection rule') + return service_response_error(message: error_message) + end + + package_protection_rule = project.package_protection_rules.create(params.slice(*ALLOWED_ATTRIBUTES)) + + unless package_protection_rule.persisted? + return service_response_error(message: package_protection_rule.errors.full_messages) + end + + ServiceResponse.success(payload: { package_protection_rule: package_protection_rule }) + rescue StandardError => e + service_response_error(message: e.message) + end + + private + + def service_response_error(message:) + ServiceResponse.error( + message: message, + payload: { package_protection_rule: nil } + ) + end + end + end +end diff --git a/app/services/pages/migrate_from_legacy_storage_service.rb b/app/services/pages/migrate_from_legacy_storage_service.rb deleted file mode 100644 index d102f93e863..00000000000 --- a/app/services/pages/migrate_from_legacy_storage_service.rb +++ /dev/null @@ -1,90 +0,0 @@ -# frozen_string_literal: true - -module Pages - class MigrateFromLegacyStorageService - def initialize(logger, ignore_invalid_entries:, mark_projects_as_not_deployed:) - @logger = logger - @ignore_invalid_entries = ignore_invalid_entries - @mark_projects_as_not_deployed = mark_projects_as_not_deployed - - @migrated = 0 - @errored = 0 - @counters_lock = Mutex.new - end - - def execute_with_threads(threads:, batch_size:) - @queue = SizedQueue.new(1) - - migration_threads = start_migration_threads(threads) - - ProjectPagesMetadatum.only_on_legacy_storage.each_batch(of: batch_size) do |batch| - @queue.push(batch) - end - - @queue.close - - @logger.info(message: "Pages legacy storage migration: Waiting for threads to finish...") - migration_threads.each(&:join) - - { migrated: @migrated, errored: @errored } - end - - def execute_for_batch(project_ids) - batch = ProjectPagesMetadatum.only_on_legacy_storage.where(project_id: project_ids) # rubocop: disable CodeReuse/ActiveRecord - - process_batch(batch) - - { migrated: @migrated, errored: @errored } - end - - private - - def start_migration_threads(count) - Array.new(count) do - Thread.new do - while batch = @queue.pop - Rails.application.executor.wrap do - process_batch(batch) - end - end - end - end - end - - def process_batch(batch) - batch.with_project_route_and_deployment.each do |metadatum| - project = metadatum.project - - migrate_project(project) - end - - @logger.info(message: "Pages legacy storage migration: batch processed", migrated: @migrated, errored: @errored) - rescue StandardError => e - # This method should never raise exception otherwise all threads might be killed - # and this will result in queue starving (and deadlock) - Gitlab::ErrorTracking.track_exception(e) - @logger.error(message: "Pages legacy storage migration: failed processing a batch: #{e.message}") - end - - def migrate_project(project) - result = nil - time = Benchmark.realtime do - result = ::Pages::MigrateLegacyStorageToDeploymentService.new(project, - ignore_invalid_entries: @ignore_invalid_entries, - mark_projects_as_not_deployed: @mark_projects_as_not_deployed).execute - end - - if result[:status] == :success - @logger.info(message: "Pages legacy storage migration: project migrated: #{result[:message]}", project_id: project.id, pages_path: project.pages_path, duration: time.round(2)) - @counters_lock.synchronize { @migrated += 1 } - else - @logger.error(message: "Pages legacy storage migration: project failed to be migrated: #{result[:message]}", project_id: project.id, pages_path: project.pages_path, duration: time.round(2)) - @counters_lock.synchronize { @errored += 1 } - end - rescue StandardError => e - @counters_lock.synchronize { @errored += 1 } - @logger.error(message: "Pages legacy storage migration: project failed to be migrated: #{result[:message]}", project_id: project&.id, pages_path: project&.pages_path) - Gitlab::ErrorTracking.track_exception(e, project_id: project&.id) - end - end -end diff --git a/app/services/pages/migrate_legacy_storage_to_deployment_service.rb b/app/services/pages/migrate_legacy_storage_to_deployment_service.rb deleted file mode 100644 index 9c1671fbc15..00000000000 --- a/app/services/pages/migrate_legacy_storage_to_deployment_service.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -module Pages - class MigrateLegacyStorageToDeploymentService - include BaseServiceUtility - - attr_reader :project - - def initialize(project, ignore_invalid_entries: false, mark_projects_as_not_deployed: false) - @project = project - @ignore_invalid_entries = ignore_invalid_entries - @mark_projects_as_not_deployed = mark_projects_as_not_deployed - end - - def execute - zip_result = ::Pages::ZipDirectoryService.new(project.pages_path, ignore_invalid_entries: @ignore_invalid_entries).execute - - if zip_result[:status] == :error - return error("Can't create zip archive: #{zip_result[:message]}") - end - - archive_path = zip_result[:archive_path] - - unless archive_path - return error("Archive not created. Missing public directory in #{@project.pages_path}") unless @mark_projects_as_not_deployed - - project.set_first_pages_deployment!(nil) - - return success( - message: "Archive not created. Missing public directory in #{project.pages_path}? Marked project as not deployed") - end - - deployment = nil - File.open(archive_path) do |file| - deployment = project.pages_deployments.create!( - file: file, - file_count: zip_result[:entries_count], - file_sha256: Digest::SHA256.file(archive_path).hexdigest - ) - end - - project.set_first_pages_deployment!(deployment) - - success - ensure - FileUtils.rm_f(archive_path) if archive_path - end - end -end diff --git a/app/services/pages/zip_directory_service.rb b/app/services/pages/zip_directory_service.rb deleted file mode 100644 index c9029b9666a..00000000000 --- a/app/services/pages/zip_directory_service.rb +++ /dev/null @@ -1,97 +0,0 @@ -# frozen_string_literal: true - -module Pages - class ZipDirectoryService - include BaseServiceUtility - include Gitlab::Utils::StrongMemoize - - # used only to track exceptions in Sentry - InvalidEntryError = Class.new(StandardError) - - PUBLIC_DIR = 'public' - - attr_reader :public_dir, :real_dir - - def initialize(input_dir, ignore_invalid_entries: false) - @input_dir = input_dir - @ignore_invalid_entries = ignore_invalid_entries - end - - def execute - return success unless resolve_public_dir - - output_file = File.join(real_dir, "@migrated.zip") # '@' to avoid any name collision with groups or projects - - FileUtils.rm_f(output_file) - - entries_count = 0 - # Since we're writing not reading here, we can safely silence the cop. - # It currently cannot discern between opening for reading or writing. - ::Zip::File.open(output_file, ::Zip::File::CREATE) do |zipfile| # rubocop:disable Performance/Rubyzip - write_entry(zipfile, PUBLIC_DIR) - entries_count = zipfile.entries.count - end - - success(archive_path: output_file, entries_count: entries_count) - rescue StandardError => e - FileUtils.rm_f(output_file) if output_file - raise e - end - - private - - def resolve_public_dir - @real_dir = File.realpath(@input_dir) - @public_dir = File.join(real_dir, PUBLIC_DIR) - - valid_path?(public_dir) - rescue Errno::ENOENT - false - end - - def write_entry(zipfile, zipfile_path) - disk_file_path = File.join(real_dir, zipfile_path) - - unless valid_path?(disk_file_path) - # archive with invalid entry will just have this entry missing - raise InvalidEntryError, "#{disk_file_path} is invalid, input_dir: #{@input_dir}" - end - - ftype = File.lstat(disk_file_path).ftype - case ftype - when 'directory' - recursively_zip_directory(zipfile, disk_file_path, zipfile_path) - when 'file', 'link' - zipfile.add(zipfile_path, disk_file_path) - else - raise InvalidEntryError, "#{disk_file_path} has invalid ftype: #{ftype}, input_dir: #{@input_dir}" - end - rescue Errno::ENOENT, Errno::ELOOP, InvalidEntryError => e - Gitlab::ErrorTracking.track_exception(e, input_dir: @input_dir, disk_file_path: disk_file_path) - - raise e unless @ignore_invalid_entries - end - - def recursively_zip_directory(zipfile, disk_file_path, zipfile_path) - zipfile.mkdir(zipfile_path) - - entries = Dir.entries(disk_file_path) - %w[. ..] - entries = entries.map { |entry| File.join(zipfile_path, entry) } - - write_entries(zipfile, entries) - end - - def write_entries(zipfile, entries) - entries.each do |zipfile_path| - write_entry(zipfile, zipfile_path) - end - end - - # SafeZip was introduced only recently, - # so we have invalid entries on disk - def valid_path?(disk_file_path) - realpath = File.realpath(disk_file_path) - realpath == public_dir || realpath.start_with?(public_dir + "/") - end - end -end diff --git a/app/services/projects/after_rename_service.rb b/app/services/projects/after_rename_service.rb index 9dc957b5be2..93d68eec3bc 100644 --- a/app/services/projects/after_rename_service.rb +++ b/app/services/projects/after_rename_service.rb @@ -62,13 +62,9 @@ module Projects def rename_or_migrate_repository! success = - if migrate_to_hashed_storage? - ::Projects::HashedStorage::MigrationService - .new(project, full_path_before) - .execute - else - project.storage.rename_repo(old_full_path: full_path_before, new_full_path: full_path_after) - end + ::Projects::HashedStorage::MigrationService + .new(project, full_path_before) + .execute rename_failed! unless success end @@ -105,11 +101,6 @@ module Projects ) end - def migrate_to_hashed_storage? - Gitlab::CurrentSettings.hashed_storage_enabled? && - project.storage_upgradable? - end - def send_move_instructions? !project.import_started? end @@ -147,5 +138,3 @@ module Projects end end end - -Projects::AfterRenameService.prepend_mod_with('Projects::AfterRenameService') 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 61b09de1643..45557d03502 100644 --- a/app/services/projects/container_repository/cleanup_tags_base_service.rb +++ b/app/services/projects/container_repository/cleanup_tags_base_service.rb @@ -100,7 +100,7 @@ module Projects def older_than_in_seconds strong_memoize(:older_than_in_seconds) do - ChronicDuration.parse(older_than, use_complete_matcher: true).seconds + ChronicDuration.parse(older_than).seconds end end end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index e4987438c57..e0ee3683ac8 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -229,7 +229,7 @@ module Projects %w[routes redirect_routes], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424281' ) do ApplicationRecord.transaction do - @project.create_or_update_import_data(data: @import_data[:data], credentials: @import_data[:credentials]) if @import_data + @project.build_or_assign_import_data(data: @import_data[:data], credentials: @import_data[:credentials]) if @import_data # Avoid project callbacks being triggered multiple times by saving the parent first. # See https://github.com/rails/rails/issues/41701. diff --git a/app/services/projects/group_links/create_service.rb b/app/services/projects/group_links/create_service.rb index f77bae71d63..c9642fb495a 100644 --- a/app/services/projects/group_links/create_service.rb +++ b/app/services/projects/group_links/create_service.rb @@ -16,7 +16,7 @@ module Projects delegate :root_ancestor, to: :project def valid_to_create? - can?(current_user, :read_namespace, shared_with_group) && sharing_allowed? + can?(current_user, :read_namespace_via_membership, shared_with_group) && sharing_allowed? end def build_link diff --git a/app/services/projects/hashed_storage/base_repository_service.rb b/app/services/projects/hashed_storage/base_repository_service.rb deleted file mode 100644 index 6241a3e144f..00000000000 --- a/app/services/projects/hashed_storage/base_repository_service.rb +++ /dev/null @@ -1,115 +0,0 @@ -# frozen_string_literal: true - -module Projects - module HashedStorage - # Returned when repository can't be made read-only because there is already a git transfer in progress - RepositoryInUseError = Class.new(StandardError) - - class BaseRepositoryService < BaseService - include Gitlab::ShellAdapter - - attr_reader :old_disk_path, :new_disk_path, :old_storage_version, - :logger, :move_wiki, :move_design - - def initialize(project:, old_disk_path:, logger: nil) - @project = project - @logger = logger || Gitlab::AppLogger - @old_disk_path = old_disk_path - @move_wiki = has_wiki? - @move_design = has_design? - end - - protected - - def has_wiki? - gitlab_shell.repository_exists?(project.repository_storage, "#{old_wiki_disk_path}.git") - end - - def has_design? - gitlab_shell.repository_exists?(project.repository_storage, "#{old_design_disk_path}.git") - end - - def move_repository(from_name, to_name) - from_exists = gitlab_shell.repository_exists?(project.repository_storage, "#{from_name}.git") - to_exists = gitlab_shell.repository_exists?(project.repository_storage, "#{to_name}.git") - - # If we don't find the repository on either original or target we should log that as it could be an issue if the - # project was not originally empty. - if !from_exists && !to_exists - logger.warn "Can't find a repository on either source or target paths for #{project.full_path} (ID=#{project.id}) ..." - - # We return true so we still reflect the change in the database. - # Next time the repository is (re)created it will be under the new storage layout - return true - elsif !from_exists - # Repository have been moved already. - return true - end - - gitlab_shell.mv_repository(project.repository_storage, from_name, to_name).tap do |moved| - if moved - logger.info("Repository moved from '#{from_name}' to '#{to_name}' (PROJECT_ID=#{project.id})") - else - logger.error("Repository cannot be moved from '#{from_name}' to '#{to_name}' (PROJECT_ID=#{project.id})") - end - end - end - - def move_repositories - result = move_repository(old_disk_path, new_disk_path) - project.reload_repository! - - if move_wiki - result &&= move_repository(old_wiki_disk_path, new_wiki_disk_path) - project.clear_memoization(:wiki) - end - - if move_design - result &&= move_repository(old_design_disk_path, new_design_disk_path) - project.clear_memoization(:design_repository) - end - - result - end - - def rollback_folder_move - move_repository(new_disk_path, old_disk_path) - move_repository(new_wiki_disk_path, old_wiki_disk_path) - move_repository(new_design_disk_path, old_design_disk_path) if move_design - end - - def try_to_set_repository_read_only! - project.set_repository_read_only! - rescue Project::RepositoryReadOnlyError => err - migration_error = "Target repository '#{old_disk_path}' cannot be made read-only: #{err.message}" - logger.error migration_error - - raise RepositoryInUseError, migration_error - end - - def wiki_path_suffix - @wiki_path_suffix ||= Gitlab::GlRepository::WIKI.path_suffix - end - - def old_wiki_disk_path - @old_wiki_disk_path ||= "#{old_disk_path}#{wiki_path_suffix}" - end - - def new_wiki_disk_path - @new_wiki_disk_path ||= "#{new_disk_path}#{wiki_path_suffix}" - end - - def design_path_suffix - @design_path_suffix ||= ::Gitlab::GlRepository::DESIGN.path_suffix - end - - def old_design_disk_path - @old_design_disk_path ||= "#{old_disk_path}#{design_path_suffix}" - end - - def new_design_disk_path - @new_design_disk_path ||= "#{new_disk_path}#{design_path_suffix}" - end - end - end -end diff --git a/app/services/projects/hashed_storage/migrate_repository_service.rb b/app/services/projects/hashed_storage/migrate_repository_service.rb deleted file mode 100644 index b65d0e63fd3..00000000000 --- a/app/services/projects/hashed_storage/migrate_repository_service.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -module Projects - module HashedStorage - class MigrateRepositoryService < BaseRepositoryService - def execute - try_to_set_repository_read_only! - - @old_storage_version = project.storage_version - project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:repository] - - @new_disk_path = project.disk_path - - result = move_repositories - - if result - project.set_full_path - project.track_project_repository - else - rollback_folder_move - project.storage_version = nil - end - - project.transaction do - project.save!(validate: false) - project.set_repository_writable! - end - - result - rescue Gitlab::Git::CommandError => e - logger.error("Repository #{project.full_path} failed to upgrade (PROJECT_ID=#{project.id}). Git operation failed: #{e.inspect}") - - rollback_migration! - - false - rescue OpenSSL::Cipher::CipherError => e - logger.error("Repository #{project.full_path} failed to upgrade (PROJECT_ID=#{project.id}). There is a problem with encrypted attributes: #{e.inspect}") - - rollback_migration! - - false - end - - private - - def rollback_migration! - rollback_folder_move - project.storage_version = nil - project.set_repository_writable! - end - end - end -end - -Projects::HashedStorage::MigrateRepositoryService.prepend_mod_with('Projects::HashedStorage::MigrateRepositoryService') diff --git a/app/services/projects/hashed_storage/migration_service.rb b/app/services/projects/hashed_storage/migration_service.rb index 57a775a8f9e..e2015a4cca6 100644 --- a/app/services/projects/hashed_storage/migration_service.rb +++ b/app/services/projects/hashed_storage/migration_service.rb @@ -12,11 +12,6 @@ module Projects end def execute - # Migrate repository from Legacy to Hashed Storage - unless project.hashed_storage?(:repository) - return false unless migrate_repository_service.execute - end - # Migrate attachments from Legacy to Hashed Storage unless project.hashed_storage?(:attachments) return false unless migrate_attachments_service.execute @@ -27,10 +22,6 @@ module Projects private - def migrate_repository_service - HashedStorage::MigrateRepositoryService.new(project: project, old_disk_path: old_disk_path, logger: logger) - end - def migrate_attachments_service HashedStorage::MigrateAttachmentsService.new(project: project, old_disk_path: old_disk_path, logger: logger) end diff --git a/app/services/projects/hashed_storage/rollback_attachments_service.rb b/app/services/projects/hashed_storage/rollback_attachments_service.rb deleted file mode 100644 index 4bb8cb605a3..00000000000 --- a/app/services/projects/hashed_storage/rollback_attachments_service.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -module Projects - module HashedStorage - class RollbackAttachmentsService < BaseAttachmentService - def execute - origin = FileUploader.absolute_base_dir(project) - - project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:repository] - target = FileUploader.absolute_base_dir(project) - - @new_disk_path = FileUploader.base_dir(project) - - result = move_folder!(origin, target) - - if result - project.save!(validate: false) - - yield if block_given? - else - # Rollback changes - project.rollback! - end - - result - end - end - end -end diff --git a/app/services/projects/hashed_storage/rollback_repository_service.rb b/app/services/projects/hashed_storage/rollback_repository_service.rb deleted file mode 100644 index f4146ff9158..00000000000 --- a/app/services/projects/hashed_storage/rollback_repository_service.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -module Projects - module HashedStorage - class RollbackRepositoryService < BaseRepositoryService - def execute - try_to_set_repository_read_only! - - @old_storage_version = project.storage_version - project.storage_version = nil - - @new_disk_path = project.disk_path - - result = move_repositories - - if result - project.set_full_path - project.track_project_repository - else - rollback_folder_move - project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:repository] - end - - project.transaction do - project.save!(validate: false) - project.set_repository_writable! - end - - result - rescue Gitlab::Git::CommandError => e - logger.error("Repository #{project.full_path} failed to rollback (PROJECT_ID=#{project.id}). Git operation failed: #{e.inspect}") - - rollback_migration! - - false - rescue OpenSSL::Cipher::CipherError => e - logger.error("Repository #{project.full_path} failed to rollback (PROJECT_ID=#{project.id}). There is a problem with encrypted attributes: #{e.inspect}") - - rollback_migration! - - false - end - - private - - def rollback_migration! - rollback_folder_move - project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:repository] - project.set_repository_writable! - end - end - end -end diff --git a/app/services/projects/hashed_storage/rollback_service.rb b/app/services/projects/hashed_storage/rollback_service.rb deleted file mode 100644 index 01b343a12d1..00000000000 --- a/app/services/projects/hashed_storage/rollback_service.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -module Projects - module HashedStorage - class RollbackService < BaseService - attr_reader :logger, :old_disk_path - - def initialize(project, old_disk_path, logger: nil) - @project = project - @old_disk_path = old_disk_path - @logger = logger || Gitlab::AppLogger - end - - def execute - # Rollback attachments from Hashed Storage to Legacy - if project.hashed_storage?(:attachments) - return false unless rollback_attachments_service.execute - end - - # Rollback repository from Hashed Storage to Legacy - if project.hashed_storage?(:repository) - rollback_repository_service.execute - end - end - - private - - def rollback_attachments_service - HashedStorage::RollbackAttachmentsService.new(project: project, old_disk_path: old_disk_path, logger: logger) - end - - def rollback_repository_service - HashedStorage::RollbackRepositoryService.new(project: project, old_disk_path: old_disk_path, logger: logger) - end - end - end -end diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index e22b728cea3..fde56d8429e 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -29,7 +29,7 @@ module Projects after_execute_hook success - rescue Gitlab::UrlBlocker::BlockedUrlError, StandardError => e + rescue Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError, StandardError => e Gitlab::Import::ImportFailureService.track( project_id: project.id, error_source: self.class.name, @@ -76,7 +76,7 @@ module Projects if project.external_import? && !unknown_url? begin @resolved_address = get_resolved_address - rescue Gitlab::UrlBlocker::BlockedUrlError => e + rescue Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError => e raise e, s_("ImportProjects|Blocked import URL: %{message}") % { message: e.message } end end diff --git a/app/services/projects/in_product_marketing_campaign_emails_service.rb b/app/services/projects/in_product_marketing_campaign_emails_service.rb deleted file mode 100644 index a549d8f594e..00000000000 --- a/app/services/projects/in_product_marketing_campaign_emails_service.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -module Projects - class InProductMarketingCampaignEmailsService - include Gitlab::Experiment::Dsl - - def initialize(project, campaign) - @project = project - @campaign = campaign - @sent_email_records = ::Users::InProductMarketingEmailRecords.new - end - - def execute - send_emails - end - - private - - attr_reader :project, :campaign, :sent_email_records - - def send_emails - project_users.each do |user| - send_email(user) - end - - sent_email_records.save! - end - - def project_users - @project_users ||= project.users.merge(Users::InProductMarketingEmail.without_campaign(campaign)) - end - - def project_users_max_access_levels - ids = project_users.map(&:id) - @project_users_max_access_levels ||= project.team.max_member_access_for_user_ids(ids) - end - - def send_email(user) - return unless user.can?(:receive_notifications) - return unless target_user?(user) - - Notify.build_ios_app_guide_email(user.notification_email_or_default).deliver_later - - sent_email_records.add(user, campaign: campaign) - experiment(:build_ios_app_guide_email, project: project).track(:email_sent) - end - - def target_user?(user) - max_access_level = project_users_max_access_levels[user.id] - max_access_level >= Gitlab::Access::DEVELOPER - end - end -end diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb index 458eaec4e2e..fe19d1f051d 100644 --- a/app/services/projects/participants_service.rb +++ b/app/services/projects/participants_service.rb @@ -11,8 +11,8 @@ module Projects noteable_owner + participants_in_noteable + all_members + - groups + - project_members + project_members + + groups render_participants_as_hash(participants.uniq) end diff --git a/app/services/projects/record_target_platforms_service.rb b/app/services/projects/record_target_platforms_service.rb index 664e72e9785..d43b587154b 100644 --- a/app/services/projects/record_target_platforms_service.rb +++ b/app/services/projects/record_target_platforms_service.rb @@ -28,26 +28,11 @@ module Projects project_setting.target_platforms = target_platforms project_setting.save - - send_build_ios_app_guide_email - project_setting.target_platforms end def project_setting @project_setting ||= ::ProjectSetting.find_or_initialize_by(project: project) # rubocop:disable CodeReuse/ActiveRecord end - - def experiment_candidate? - experiment(:build_ios_app_guide_email, project: project).run - end - - def send_build_ios_app_guide_email - return unless target_platforms.include? :ios - return unless experiment_candidate? - - campaign = Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE - Projects::InProductMarketingCampaignEmailsService.new(project, campaign).execute - end end end diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index 3d08039942b..30d9e1922cc 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -124,9 +124,6 @@ module Projects # Notifications project.send_move_instructions(@old_path) - # Directories on disk - move_project_folders(project) - transfer_missing_group_resources(@old_group) # Move uploads @@ -235,44 +232,15 @@ module Projects end def rollback_side_effects - rollback_folder_move project.reset update_namespace_and_visibility(@old_namespace) update_repository_configuration(@old_path) end - def rollback_folder_move - return if project.hashed_storage?(:repository) - - move_repo_folder(@new_path, @old_path) - move_repo_folder(new_wiki_repo_path, old_wiki_repo_path) - move_repo_folder(new_design_repo_path, old_design_repo_path) - end - - def move_repo_folder(from_name, to_name) - gitlab_shell.mv_repository(project.repository_storage, from_name, to_name) - end - def execute_system_hooks system_hook_service.execute_hooks_for(project, :transfer) end - def move_project_folders(project) - return if project.hashed_storage?(:repository) - - # Move main repository - unless move_repo_folder(@old_path, @new_path) - raise TransferError, s_("TransferProject|Cannot move project") - end - - # Disk path is changed; we need to ensure we reload it - project.reload_repository! - - # Move wiki and design repos also if present - move_repo_folder(old_wiki_repo_path, new_wiki_repo_path) - move_repo_folder(old_design_repo_path, new_design_repo_path) - end - def move_project_uploads(project) return if project.hashed_storage?(:attachments) diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index dc92c501b8c..ab38efff7c9 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -109,11 +109,6 @@ module Projects PagesDeployment.deactivate_deployments_older_than( deployment, time: OLD_DEPLOYMENTS_DESTRUCTION_DELAY.from_now) - - DestroyPagesDeploymentsWorker.perform_in( - OLD_DEPLOYMENTS_DESTRUCTION_DELAY, - project.id, - deployment.id) end def register_attempt diff --git a/app/services/projects/update_repository_storage_service.rb b/app/services/projects/update_repository_storage_service.rb index 799ae5677c3..85fb1890fcd 100644 --- a/app/services/projects/update_repository_storage_service.rb +++ b/app/services/projects/update_repository_storage_service.rb @@ -48,13 +48,8 @@ module Projects pool_repository: pool_repository ) - checksum, new_checksum = replicate_object_pool_repository(from: pool_repository, to: target_pool_repository) - - if checksum != new_checksum - raise Error, - format(s_('UpdateRepositoryStorage|Failed to verify %{type} repository checksum from %{old} to %{new}'), - type: 'object_pool', old: checksum, new: new_checksum) - end + Repositories::ReplicateService.new(pool_repository.object_pool.repository) + .execute(target_pool_repository.object_pool.repository, :object_pool) end def remove_old_paths @@ -96,19 +91,6 @@ module Projects ) end - def replicate_object_pool_repository(from:, to:) - old_object_pool = from.object_pool - new_object_pool = to.object_pool - - checksum = old_object_pool.repository.checksum - - new_object_pool.repository.replicate(old_object_pool.repository) - - new_checksum = new_object_pool.repository.checksum - - [checksum, new_checksum] - end - def replicate_object_pool_on_move_ff_enabled? Feature.enabled?(:replicate_object_pool_on_move, project) end diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb index f0243d844d9..95e0861a37a 100644 --- a/app/services/releases/create_service.rb +++ b/app/services/releases/create_service.rb @@ -19,7 +19,7 @@ module Releases return tag unless tag.is_a?(Gitlab::Git::Tag) if project.catalog_resource - response = Ci::Catalog::ValidateResourceService.new(project, ref).execute + response = Ci::Catalog::Resources::ValidateService.new(project, ref).execute return error(response.message) if response.error? end diff --git a/app/services/releases/destroy_service.rb b/app/services/releases/destroy_service.rb index 41b421662ef..78613c05ff1 100644 --- a/app/services/releases/destroy_service.rb +++ b/app/services/releases/destroy_service.rb @@ -9,6 +9,8 @@ module Releases if release.destroy update_catalog_resource! + execute_hooks(release, 'delete') + success(tag: existing_tag, release: release) else error(release.errors.messages || '400 Bad request', 400) diff --git a/app/services/repositories/base_service.rb b/app/services/repositories/base_service.rb index bf7ac2e5fd8..371ff2fc499 100644 --- a/app/services/repositories/base_service.rb +++ b/app/services/repositories/base_service.rb @@ -15,10 +15,6 @@ class Repositories::BaseService < BaseService gitlab_shell.repository_exists?(repository.shard, path + '.git') end - def mv_repository(from_path, to_path) - gitlab_shell.mv_repository(repository.shard, from_path, to_path) - end - # If we get a Gitaly error, the repository may be corrupted. We can # ignore these errors since we're going to trash the repositories # anyway. diff --git a/app/services/repositories/replicate_service.rb b/app/services/repositories/replicate_service.rb new file mode 100644 index 00000000000..0148223910f --- /dev/null +++ b/app/services/repositories/replicate_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Repositories + class ReplicateService < Repositories::BaseService + Error = Class.new(StandardError) + + def execute(new_repository, type) + new_repository.replicate(repository) + + new_checksum = new_repository.checksum + checksum = repository.checksum + + return if new_checksum == checksum + + raise Error, format(s_( + 'ReplicateService|Failed to verify %{type} repository checksum from %{old} to %{new}' + ), type: type, old: checksum, new: new_checksum) + rescue StandardError => e + new_repository.remove + + raise e + end + end +end diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb index 9efe51b43b8..2d4bebc8b2b 100644 --- a/app/services/spam/spam_verdict_service.rb +++ b/app/services/spam/spam_verdict_service.rb @@ -90,7 +90,7 @@ module Spam end def allow_possible_spam? - target.allow_possible_spam?(user) || user.allow_possible_spam? + target.allow_possible_spam?(user) || user.trusted? end def spamcheck_client diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb index 04ae734a8fe..8442ff81d41 100644 --- a/app/services/system_notes/issuables_service.rb +++ b/app/services/system_notes/issuables_service.rb @@ -24,15 +24,16 @@ module SystemNotes end # - # noteable_ref - Referenced noteable object + # noteable_ref - Referenced noteable object, or array of objects # # Example Note text: # # "marked this issue as related to gitlab-foss#9001" + # "marked this issue as related to gitlab-foss#9001, gitlab-foss#9002, and gitlab-foss#9003" # # Returns the created Note object def relate_issuable(noteable_ref) - body = "marked this #{noteable_name} as related to #{noteable_ref.to_reference(noteable.resource_parent)}" + body = "marked this #{noteable_name} as related to #{extract_issuable_reference(noteable_ref)}" track_issue_event(:track_issue_related_action) @@ -539,6 +540,14 @@ module SystemNotes name.humanize(capitalize: false) end + + def extract_issuable_reference(reference) + if reference.is_a?(Array) + reference.map { |item| item.to_reference(noteable.resource_parent) }.to_sentence + else + reference.to_reference(noteable.resource_parent) + end + end end end diff --git a/app/services/tasks_to_be_done/base_service.rb b/app/services/tasks_to_be_done/base_service.rb deleted file mode 100644 index 1d50e5081ff..00000000000 --- a/app/services/tasks_to_be_done/base_service.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -module TasksToBeDone - class BaseService < ::BaseContainerService - LABEL_PREFIX = 'tasks to be done' - - def initialize(container:, current_user:, assignee_ids: []) - params = { - assignee_ids: assignee_ids, - title: title, - description: description, - add_labels: label_name - } - super(container: container, current_user: current_user, params: params) - end - - def execute - if (issue = existing_task_issue) - update_service = Issues::UpdateService.new(container: project, current_user: current_user, params: { add_assignee_ids: params[:assignee_ids] }) - update_service.execute(issue) - else - create_service = Issues::CreateService.new(container: project, current_user: current_user, params: params, perform_spam_check: false) - create_service.execute - end - end - - private - - def existing_task_issue - IssuesFinder.new( - current_user, - project_id: project.id, - state: 'opened', - non_archived: true, - label_name: label_name - ).execute.last - end - - def title - raise NotImplementedError - end - - def description - raise NotImplementedError - end - - def label_suffix - raise NotImplementedError - end - - def label_name - "#{LABEL_PREFIX}:#{label_suffix}" - end - end -end diff --git a/app/services/tasks_to_be_done/create_ci_task_service.rb b/app/services/tasks_to_be_done/create_ci_task_service.rb deleted file mode 100644 index 025ca2feb8e..00000000000 --- a/app/services/tasks_to_be_done/create_ci_task_service.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -module TasksToBeDone - class CreateCiTaskService < BaseService - protected - - def title - 'Set up CI/CD' - end - - def description - <<~DESCRIPTION - GitLab CI/CD is a tool built into GitLab for software development through the [continuous methodologies](https://docs.gitlab.com/ee/ci/introduction/index.html#introduction-to-cicd-methodologies): - - * Continuous Integration (CI) - * Continuous Delivery (CD) - * Continuous Deployment (CD) - - Continuous Integration works by pushing small changes to your application’s codebase hosted in a Git repository, and, to every push, run a pipeline of scripts to build, test, and validate the code changes before merging them into the main branch. - - Continuous Delivery and Deployment consist of a step further CI, deploying your application to production at every push to the default branch of the repository. - - These methodologies allow you to catch bugs and errors early in the development cycle, ensuring that all the code deployed to production complies with the code standards you established for your app. - - * :book: [Read the documentation](https://docs.gitlab.com/ee/ci/introduction/index.html) - * :clapper: [Watch a Demo](https://www.youtube.com/watch?v=1iXFbchozdY) - - ## Next steps - - * [ ] To start we recommend reviewing the following documentation: - * [ ] [How GitLab CI/CD works.](https://docs.gitlab.com/ee/ci/introduction/index.html#how-gitlab-cicd-works) - * [ ] [Fundamental pipeline architectures.](https://docs.gitlab.com/ee/ci/pipelines/pipeline_architectures.html) - * [ ] [GitLab CI/CD basic workflow.](https://docs.gitlab.com/ee/ci/introduction/index.html#basic-cicd-workflow) - * [ ] [Step-by-step guide for writing .gitlab-ci.yml for the first time.](https://docs.gitlab.com/ee/user/project/pages/getting_started_part_four.html) - * [ ] When you're ready select **Projects** (in the top navigation bar) > **Your projects** > select the Project you've already created. - * [ ] Select **CI / CD** in the left navigation to start setting up CI / CD in your project. - DESCRIPTION - end - - def label_suffix - 'ci' - end - end -end diff --git a/app/services/tasks_to_be_done/create_code_task_service.rb b/app/services/tasks_to_be_done/create_code_task_service.rb deleted file mode 100644 index dc3b9366a66..00000000000 --- a/app/services/tasks_to_be_done/create_code_task_service.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -module TasksToBeDone - class CreateCodeTaskService < BaseService - protected - - def title - 'Create or import your code into your Project (Repository)' - end - - def description - <<~DESCRIPTION - You've already created your Group and Project within GitLab; we'll quickly review this hierarchy below. Once you're within your project you can easily create or import repositories. - - **With GitLab Groups, you can:** - - * Create one or multiple Projects for hosting your codebase (repositories). - * Assemble related projects together. - * Grant members access to several projects at once. - - Groups can also be nested in subgroups. - - Read more about groups in our [documentation](https://docs.gitlab.com/ee/user/group/). - - **Within GitLab Projects, you can** - - * Use it as an issue tracker. - * Collaborate on code. - * Continuously build, test, and deploy your app with built-in GitLab CI/CD. - - You can also import an existing repository by providing the Git URL. - - * :book: [Read the documentation](https://docs.gitlab.com/ee/user/project/index.html). - - ## Next steps - - Create or import your first repository into the project you created: - - * [ ] Click **Projects** in the top navigation bar, then click **Your projects**. - * [ ] Select the Project that you created, then select **Repository**. - * [ ] Once on the Repository page you can select the **+** icon to add or import files. - * [ ] You can review our full documentation on creating [repositories](https://docs.gitlab.com/ee/user/project/repository/) in GitLab. - - :tada: All done, you can close this issue! - DESCRIPTION - end - - def label_suffix - 'code' - end - end -end diff --git a/app/services/tasks_to_be_done/create_issues_task_service.rb b/app/services/tasks_to_be_done/create_issues_task_service.rb deleted file mode 100644 index a2de6852868..00000000000 --- a/app/services/tasks_to_be_done/create_issues_task_service.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -module TasksToBeDone - class CreateIssuesTaskService < BaseService - protected - - def title - 'Create/import issues (tickets) to collaborate on ideas and plan work' - end - - def description - <<~DESCRIPTION - Issues allow you and your team to discuss proposals before, and during, their implementation. They can be used for a variety of other purposes, customized to your needs and workflow. - - Issues are always associated with a specific project. If you have multiple projects in a group, you can view all the issues at the group level. [You can review our full Issue documentation here.](https://docs.gitlab.com/ee/user/project/issues/) - - If you have existing issues or equivalent tickets you can import them as long as they are formatted as a CSV file, [the import process is covered here](https://docs.gitlab.com/ee/user/project/issues/csv_import.html). - - **Common use cases include:** - - * Discussing the implementation of a new idea - * Tracking tasks and work status - * Accepting feature proposals, questions, support requests, or bug reports - * Elaborating on new code implementations - - ## Next steps - - * [ ] Select **Projects** in the top navigation > **Your Projects** > select the Project you've already created. - * [ ] Once you've selected that project, you can select **Issues** in the left navigation, then click **New issue**. - * [ ] Fill in the title and description in the **New issue** page. - * [ ] Click on **Create issue**. - - Pro tip: When you're in a group or project you can always utilize the **+** icon in the top navigation (located to the left of the search bar) to quickly create new issues. - - That's it! You can close this issue. - DESCRIPTION - end - - def label_suffix - 'issues' - end - end -end diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 1f6cf2c83c9..be7405cc896 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -168,7 +168,7 @@ class TodoService def mark_todo(target, current_user) project = target.project attributes = attributes_for_todo(project, target, current_user, Todo::MARKED) - create_todos(current_user, attributes, project&.namespace, project) + create_todos(current_user, attributes, target_namespace(target), project) end def todo_exist?(issuable, current_user) @@ -338,7 +338,7 @@ class TodoService project = target.project assignees = target.assignees - old_assignees attributes = attributes_for_todo(project, target, author, Todo::ASSIGNED) - create_todos(assignees, attributes, project.namespace, project) + create_todos(assignees, attributes, target_namespace(target), project) end end @@ -386,6 +386,7 @@ class TodoService attributes.merge!(target_id: nil, commit_id: target.id) when Issue attributes[:issue_type] = target.issue_type + attributes[:group] = target.namespace if target.project.blank? when Discussion attributes.merge!(target_type: nil, target_id: nil, discussion: target) end @@ -469,6 +470,11 @@ class TodoService attributes end + + def target_namespace(target) + project = target.project + project&.namespace || target.try(:namespace) + end end TodoService.prepend_mod_with('TodoService') diff --git a/app/services/todos/destroy/base_service.rb b/app/services/todos/destroy/base_service.rb index ed5c4df85b1..c9c86330e1c 100644 --- a/app/services/todos/destroy/base_service.rb +++ b/app/services/todos/destroy/base_service.rb @@ -4,37 +4,6 @@ module Todos module Destroy class BaseService def execute - return unless todos_to_remove? - - ::Gitlab::Database.allow_cross_joins_across_databases(url: - 'https://gitlab.com/gitlab-org/gitlab/-/issues/422045') do - without_authorized(todos).delete_all - end - end - - private - - # rubocop: disable CodeReuse/ActiveRecord - def without_authorized(items) - items.where.not('todos.user_id' => authorized_users) - end - # rubocop: enable CodeReuse/ActiveRecord - - # rubocop: disable CodeReuse/ActiveRecord - def authorized_users - ProjectAuthorization.select(:user_id).where(project_id: project_ids) - end - # rubocop: enable CodeReuse/ActiveRecord - - def todos - raise NotImplementedError - end - - def project_ids - raise NotImplementedError - end - - def todos_to_remove? raise NotImplementedError end end diff --git a/app/services/todos/destroy/confidential_issue_service.rb b/app/services/todos/destroy/confidential_issue_service.rb index fadc76b1181..331c4a12681 100644 --- a/app/services/todos/destroy/confidential_issue_service.rb +++ b/app/services/todos/destroy/confidential_issue_service.rb @@ -9,58 +9,59 @@ module Todos # When issue_id is passed it deletes matching todos for one confidential issue. # When project_id is passed it deletes matching todos for all confidential issues of the project. class ConfidentialIssueService < ::Todos::Destroy::BaseService - extend ::Gitlab::Utils::Override - attr_reader :issues - # rubocop: disable CodeReuse/ActiveRecord def initialize(issue_id: nil, project_id: nil) @issues = if issue_id - Issue.where(id: issue_id) + Issue.id_in(issue_id) elsif project_id project_confidential_issues(project_id) end end - # rubocop: enable CodeReuse/ActiveRecord + + def execute + return unless todos_to_remove? + + ::Gitlab::Database.allow_cross_joins_across_databases( + url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422045') do + delete_todos + end + end private + def delete_todos + authorized_users = ProjectAuthorization.select(:user_id) + .for_project(project_ids) + .non_guests + + todos.not_in_users(authorized_users).delete_all + end + def project_confidential_issues(project_id) project = Project.find(project_id) project.issues.confidential_only end - override :todos # rubocop: disable CodeReuse/ActiveRecord def todos Todo.joins_issue_and_assignees - .where(target: issues) - .where(issues: { confidential: true }) + .for_target(issues) + .merge(Issue.confidential_only) .where('todos.user_id != issues.author_id') .where('todos.user_id != issue_assignees.user_id') end # rubocop: enable CodeReuse/ActiveRecord - override :todos_to_remove? def todos_to_remove? issues&.any?(&:confidential?) end - override :project_ids def project_ids issues&.distinct&.select(:project_id) end - - override :authorized_users - # rubocop: disable CodeReuse/ActiveRecord - def authorized_users - ProjectAuthorization.select(:user_id) - .where(project_id: project_ids) - .where('access_level >= ?', Gitlab::Access::REPORTER) - end - # rubocop: enable CodeReuse/ActiveRecord end end end diff --git a/app/services/todos/destroy/group_private_service.rb b/app/services/todos/destroy/group_private_service.rb index 60599ca9ca4..6962c204e0e 100644 --- a/app/services/todos/destroy/group_private_service.rb +++ b/app/services/todos/destroy/group_private_service.rb @@ -7,30 +7,34 @@ module Todos attr_reader :group - # rubocop: disable CodeReuse/ActiveRecord def initialize(group_id) - @group = Group.find_by(id: group_id) + @group = Group.find_by_id(group_id) + end + + def execute + return unless todos_to_remove? + + delete_todos end - # rubocop: enable CodeReuse/ActiveRecord private - override :todos - # rubocop: disable CodeReuse/ActiveRecord - def todos - Todo.where(group_id: group.id) + def delete_todos + authorized_users = Member.from_union( + [ + group.descendant_project_members_with_inactive.select(:user_id), + group.members_with_parents.select(:user_id) + ], + remove_duplicates: false + ).select(:user_id) + + todos.not_in_users(authorized_users).delete_all end - # rubocop: enable CodeReuse/ActiveRecord - - override :authorized_users - def authorized_users - User.from_union([ - group.project_users_with_descendants.select(:id), - group.members_with_parents.select(:user_id) - ], remove_duplicates: false) + + def todos + Todo.for_group(group.id) end - override :todos_to_remove? def todos_to_remove? group&.private? end diff --git a/app/services/todos/destroy/project_private_service.rb b/app/services/todos/destroy/project_private_service.rb index e00d10c3780..a1ca0d8543c 100644 --- a/app/services/todos/destroy/project_private_service.rb +++ b/app/services/todos/destroy/project_private_service.rb @@ -7,27 +7,32 @@ module Todos attr_reader :project - # rubocop: disable CodeReuse/ActiveRecord def initialize(project_id) - @project = Project.find_by(id: project_id) + @project = Project.find_by_id(project_id) + end + + def execute + return unless todos_to_remove? + + delete_todos end - # rubocop: enable CodeReuse/ActiveRecord private - override :todos - # rubocop: disable CodeReuse/ActiveRecord + def delete_todos + authorized_users = ProjectAuthorization.select(:user_id).for_project(project_ids) + + todos.not_in_users(authorized_users).delete_all + end + def todos - Todo.where(project_id: project.id) + Todo.for_project(project.id) end - # rubocop: enable CodeReuse/ActiveRecord - override :project_ids def project_ids project.id end - override :todos_to_remove? def todos_to_remove? project&.private? end diff --git a/app/services/todos/destroy/unauthorized_features_service.rb b/app/services/todos/destroy/unauthorized_features_service.rb index 513def10575..22f7a0b2a37 100644 --- a/app/services/todos/destroy/unauthorized_features_service.rb +++ b/app/services/todos/destroy/unauthorized_features_service.rb @@ -27,6 +27,14 @@ module Todos private + def without_authorized(items) + items.not_in_users(authorized_users) + end + + def authorized_users + ProjectAuthorization.select(:user_id).for_project(project_ids) + end + def related_todos base_scope = Todo.for_project(project_id) base_scope = base_scope.for_user(user_id) if user_id diff --git a/app/services/update_container_registry_info_service.rb b/app/services/update_container_registry_info_service.rb index 7d79b257687..b720f9b14a5 100644 --- a/app/services/update_container_registry_info_service.rb +++ b/app/services/update_container_registry_info_service.rb @@ -11,7 +11,7 @@ class UpdateContainerRegistryInfoService # associated user when running this (e.g. from a rake task or a cron job), # so we need to generate a valid JWT token with no access permissions to # authenticate as a trusted client. - token = Auth::ContainerRegistryAuthenticationService.access_token([], []) + token = Auth::ContainerRegistryAuthenticationService.access_token({}) client = ContainerRegistry::Client.new(registry_config.api_url, token: token) info = client.registry_info @@ -24,7 +24,8 @@ class UpdateContainerRegistryInfoService Gitlab::CurrentSettings.update!( container_registry_vendor: info[:vendor] || '', container_registry_version: info[:version] || '', - container_registry_features: info[:features] || [] + container_registry_features: info[:features] || [], + container_registry_db_enabled: info[:db_enabled] || false ) end end diff --git a/app/services/users/allow_possible_spam_service.rb b/app/services/users/allow_possible_spam_service.rb deleted file mode 100644 index d9273fe0fc1..00000000000 --- a/app/services/users/allow_possible_spam_service.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module Users - class AllowPossibleSpamService < BaseService - def initialize(current_user) - @current_user = current_user - end - - def execute(user) - custom_attribute = { - user_id: user.id, - key: UserCustomAttribute::ALLOW_POSSIBLE_SPAM, - value: "#{current_user.username}/#{current_user.id}+#{Time.current}" - } - UserCustomAttribute.upsert_custom_attributes([custom_attribute]) - end - end -end diff --git a/app/services/users/auto_ban_service.rb b/app/services/users/auto_ban_service.rb new file mode 100644 index 00000000000..fa3b738b4cd --- /dev/null +++ b/app/services/users/auto_ban_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Users + class AutoBanService < BaseService + def initialize(user:, reason:) + @user = user + @reason = reason + end + + def execute + if user.ban + record_custom_attribute + success + else + messages = user.errors.full_messages + error(messages.uniq.join('. ')) + end + end + + private + + attr_reader :user, :reason + + def record_custom_attribute + custom_attribute = { + user_id: user.id, + key: UserCustomAttribute::AUTO_BANNED_BY, + value: reason + } + UserCustomAttribute.upsert_custom_attributes([custom_attribute]) + end + end +end diff --git a/app/services/users/in_product_marketing_email_records.rb b/app/services/users/in_product_marketing_email_records.rb index 94dbd809496..fcb252536b3 100644 --- a/app/services/users/in_product_marketing_email_records.rb +++ b/app/services/users/in_product_marketing_email_records.rb @@ -13,10 +13,9 @@ module Users @records = [] end - def add(user, campaign: nil, track: nil, series: nil) + def add(user, track: nil, series: nil) @records << Users::InProductMarketingEmail.new( user: user, - campaign: campaign, track: track, series: series, created_at: Time.zone.now, diff --git a/app/services/users/set_namespace_commit_email_service.rb b/app/services/users/set_namespace_commit_email_service.rb index 30ee597120d..775db364625 100644 --- a/app/services/users/set_namespace_commit_email_service.rb +++ b/app/services/users/set_namespace_commit_email_service.rb @@ -20,7 +20,7 @@ module Users return error(_("User doesn't exist or you don't have permission to change namespace commit emails.")) end - unless can?(target_user, :read_namespace, namespace) + unless can?(target_user, :read_namespace_via_membership, namespace) return error(_("Namespace doesn't exist or you don't have permission.")) end diff --git a/app/services/users/signup_service.rb b/app/services/users/signup_service.rb deleted file mode 100644 index 9eb1e75988c..00000000000 --- a/app/services/users/signup_service.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -module Users - class SignupService < BaseService - def initialize(current_user, params = {}) - @user = current_user - @params = params.dup - end - - def execute - assign_attributes - inject_validators - - if @user.save - ServiceResponse.success - else - ServiceResponse.error(message: @user.errors.full_messages.join('. ')) - end - end - - private - - def assign_attributes - @user.assign_attributes(params) unless params.empty? - end - - def inject_validators - class << @user - validates :role, presence: true - validates :setup_for_company, inclusion: { in: [true, false], message: :blank } if Gitlab.com? - end - end - end -end diff --git a/app/services/users/disallow_possible_spam_service.rb b/app/services/users/trust_service.rb index e31ba7ddff0..faf0b9c40ea 100644 --- a/app/services/users/disallow_possible_spam_service.rb +++ b/app/services/users/trust_service.rb @@ -1,13 +1,14 @@ # frozen_string_literal: true module Users - class DisallowPossibleSpamService < BaseService + class TrustService < BaseService def initialize(current_user) @current_user = current_user end def execute(user) - user.custom_attributes.by_key(UserCustomAttribute::ALLOW_POSSIBLE_SPAM).delete_all + UserCustomAttribute.set_trusted_by(user: user, trusted_by: @current_user) + success end end end diff --git a/app/services/users/untrust_service.rb b/app/services/users/untrust_service.rb new file mode 100644 index 00000000000..aa5de71b97f --- /dev/null +++ b/app/services/users/untrust_service.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Users + class UntrustService < BaseService + def initialize(current_user) + @current_user = current_user + end + + def execute(user) + user.trusted_with_spam_attribute.delete + success + end + end +end diff --git a/app/services/verify_pages_domain_service.rb b/app/services/verify_pages_domain_service.rb index 408ee429a74..59c73aa929c 100644 --- a/app/services/verify_pages_domain_service.rb +++ b/app/services/verify_pages_domain_service.rb @@ -46,9 +46,15 @@ class VerifyPagesDomainService < BaseService notify(:verification_succeeded) end + after_successful_verification + success end + def after_successful_verification + # method overridden in EE + end + def unverify_domain! was_verified = domain.verified? @@ -115,3 +121,5 @@ class VerifyPagesDomainService < BaseService notification_service.public_send("pages_domain_#{type}", domain) # rubocop:disable GitlabSecurity/PublicSend end end + +VerifyPagesDomainService.prepend_mod diff --git a/app/services/vs_code/settings/create_or_update_service.rb b/app/services/vs_code/settings/create_or_update_service.rb new file mode 100644 index 00000000000..27688b911b7 --- /dev/null +++ b/app/services/vs_code/settings/create_or_update_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module VsCode + module Settings + class CreateOrUpdateService + def initialize(current_user:, params: {}) + @current_user = current_user + @params = params + end + + def execute + # The GitLab VSCode settings API does not support creating or updating + # machines. + return ServiceResponse.success(payload: DEFAULT_MACHINE) if @params[:setting_type] == 'machines' + + setting = VsCodeSetting.by_user(current_user).by_setting_type(params[:setting_type]).first + + if setting.nil? + merged_params = params.merge(user: current_user, uuid: SecureRandom.uuid) + setting = VsCodeSetting.new(merged_params) + else + setting.content = params[:content] + setting.uuid = SecureRandom.uuid + end + + if setting.save + ServiceResponse.success(payload: setting) + else + ServiceResponse.error( + message: setting.errors.full_messages.to_sentence, + payload: { setting: setting } + ) + end + end + + private + + attr_reader :current_user, :params + end + end +end diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index 5bad2a1583c..27b29feed50 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -36,7 +36,9 @@ class WebHookService attr_accessor :hook, :data, :hook_name, :request_options attr_reader :uniqueness_token - def self.hook_to_event(hook_name) + def self.hook_to_event(hook_name, hook = nil) + return hook.class.name.titleize if hook.is_a?(SystemHook) + hook_name.to_s.singularize.titleize end @@ -194,7 +196,7 @@ class WebHookService headers = { 'Content-Type' => 'application/json', 'User-Agent' => "GitLab/#{Gitlab::VERSION}", - Gitlab::WebHooks::GITLAB_EVENT_HEADER => self.class.hook_to_event(hook_name), + Gitlab::WebHooks::GITLAB_EVENT_HEADER => self.class.hook_to_event(hook_name, hook), Gitlab::WebHooks::GITLAB_UUID_HEADER => SecureRandom.uuid, Gitlab::WebHooks::GITLAB_INSTANCE_HEADER => Gitlab.config.gitlab.base_url } diff --git a/app/services/work_items/related_work_item_links/create_service.rb b/app/services/work_items/related_work_item_links/create_service.rb index f313881470a..38e5ba3be7f 100644 --- a/app/services/work_items/related_work_item_links/create_service.rb +++ b/app/services/work_items/related_work_item_links/create_service.rb @@ -9,6 +9,7 @@ module WorkItems return error(_('No matching work item found.'), 404) unless can?(current_user, :admin_work_item_link, issuable) response = super + create_notes_async if new_links.any? if response[:status] == :success response[:message] = format( @@ -30,6 +31,10 @@ module WorkItems private + def create_notes(_issuable_link) + # no-op notes are created asynchronously + end + def link_class WorkItems::RelatedWorkItemLink end @@ -49,9 +54,23 @@ module WorkItems created_links.collect(&:target_id) end + def create_notes_async + link_ids = new_links.collect(&:id) + + worker_params = { + issuable_class: issuable.class.name, + issuable_id: issuable.id, + link_ids: link_ids, + link_type: params[:link_type] || 'relates_to', + user_id: current_user.id + } + + Issuable::RelatedLinksCreateWorker.perform_async(worker_params) + end + override :issuables_already_assigned_message def issuables_already_assigned_message - _('Work items are already linked') + _('Items are already linked') end override :issuables_not_found_message diff --git a/app/services/work_items/widgets/labels_service/update_service.rb b/app/services/work_items/widgets/labels_service/update_service.rb index b880398677d..b0791571924 100644 --- a/app/services/work_items/widgets/labels_service/update_service.rb +++ b/app/services/work_items/widgets/labels_service/update_service.rb @@ -11,6 +11,7 @@ module WorkItems end return if params.blank? + return unless has_permission?(:set_work_item_metadata) service_params.merge!(params.slice(:add_label_ids, :remove_label_ids)) end diff --git a/app/services/work_items/widgets/start_and_due_date_service/update_service.rb b/app/services/work_items/widgets/start_and_due_date_service/update_service.rb index 0dbf3aa31d9..5d47b3a1516 100644 --- a/app/services/work_items/widgets/start_and_due_date_service/update_service.rb +++ b/app/services/work_items/widgets/start_and_due_date_service/update_service.rb @@ -8,6 +8,7 @@ module WorkItems return widget.work_item.assign_attributes({ start_date: nil, due_date: nil }) if new_type_excludes_widget? return if params.blank? + return unless has_permission?(:set_work_item_metadata) widget.work_item.assign_attributes(params.slice(:start_date, :due_date)) end diff --git a/app/validators/addressable_url_validator.rb b/app/validators/addressable_url_validator.rb index 6dcc089fa73..af7be326f51 100644 --- a/app/validators/addressable_url_validator.rb +++ b/app/validators/addressable_url_validator.rb @@ -84,7 +84,7 @@ class AddressableUrlValidator < ActiveModel::EachValidator value = strip_value!(record, attribute, value) Gitlab::UrlBlocker.validate!(value, **blocker_args) - rescue Gitlab::UrlBlocker::BlockedUrlError => e + rescue Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError => e record.errors.add(attribute, options.fetch(:blocked_message) % { exception_message: e.message }) end diff --git a/app/validators/duration_validator.rb b/app/validators/duration_validator.rb index bcdcf665cba..defd28d7d3b 100644 --- a/app/validators/duration_validator.rb +++ b/app/validators/duration_validator.rb @@ -12,7 +12,7 @@ # class DurationValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) - ChronicDuration.parse(value, use_complete_matcher: true) + ChronicDuration.parse(value) rescue ChronicDuration::DurationParseError if options[:message] record.errors.add(:base, options[:message]) diff --git a/app/validators/json_schemas/catalog_resource_component_inputs.json b/app/validators/json_schemas/catalog_resource_component_inputs.json index 014a52d4f1b..830bf684838 100644 --- a/app/validators/json_schemas/catalog_resource_component_inputs.json +++ b/app/validators/json_schemas/catalog_resource_component_inputs.json @@ -15,6 +15,9 @@ "boolean" ] }, + "regex": { + "type": "string" + }, "^type$": { "type": "string" } diff --git a/app/validators/json_schemas/vulnerability_cvss_vectors.json b/app/validators/json_schemas/vulnerability_cvss_vectors.json new file mode 100644 index 00000000000..7ec1339e974 --- /dev/null +++ b/app/validators/json_schemas/vulnerability_cvss_vectors.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Schema for cvss attribute of Vulnerability", + "type": "array", + "items": { + "type": "object", + "properties": { + "vendor": { + "type": "string", + "default": "unknown" + }, + "vector_string": { + "type": "string", + "example": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H" + } + }, + "required": [ + "vendor", + "vector_string" + ] + } +} 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 b65649b5a07..4e55c99e445 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -17,7 +17,7 @@ .form-group = f.label :receive_max_input_size, _('Maximum push size (MiB)'), class: 'label-light' - = f.number_field :receive_max_input_size, class: 'form-control gl-form-input', title: _('Maximum size limit for a single commit.'), data: { toggle: 'tooltip', container: 'body', qa_selector: 'receive_max_input_size_field' } + = f.number_field :receive_max_input_size, class: 'form-control gl-form-input', title: _('Maximum size limit for a single commit.'), data: { toggle: 'tooltip', container: 'body', testid: 'receive-max-input-size-field' } .form-group = f.label :session_expire_delay, _('Session duration (minutes)'), class: 'label-light' = f.number_field :session_expire_delay, class: 'form-control gl-form-input', title: _('Maximum duration of a session.'), data: { toggle: 'tooltip', container: 'body' } @@ -70,4 +70,4 @@ = render_if_exists 'admin/application_settings/availability_on_namespace_setting', form: f -# This is added for Jihu edition which should not be deleted without notifying Jihu = render_if_exists 'admin/application_settings/password_expiration_setting', form: f - = f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), pajamas_button: true, data: { testid: 'save-changes-button' } diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml index 65049fa5466..2d45391a839 100644 --- a/app/views/admin/application_settings/_email.html.haml +++ b/app/views/admin/application_settings/_email.html.haml @@ -18,11 +18,10 @@ .form-group = f.gitlab_ui_checkbox_component :user_deactivation_emails_enabled, _('Enable user deactivation emails'), help_text: _('Send emails to users upon account deactivation.') - - if Feature.enabled?(:deactivation_email_additional_text) - .form-group - = f.label :deactivation_email_additional_text, _('Additional text for deactivation email') - = f.text_area :deactivation_email_additional_text, class: 'form-control gl-form-input', rows: 4 - .form-text.text-muted - = _('Text added to the body of user deactivation email messages. 1000 character limit.') + .form-group + = f.label :deactivation_email_additional_text, _('Additional text for deactivation email') + = f.text_area :deactivation_email_additional_text, class: 'form-control gl-form-input', rows: 4 + .form-text.text-muted + = _('Text added to the body of user deactivation email messages. 1000 character limit.') - = f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), pajamas_button: true, data: { testid: 'save-changes-button' } diff --git a/app/views/admin/application_settings/_ip_limits.html.haml b/app/views/admin/application_settings/_ip_limits.html.haml index 01d7bf0af67..cba37527606 100644 --- a/app/views/admin/application_settings/_ip_limits.html.haml +++ b/app/views/admin/application_settings/_ip_limits.html.haml @@ -8,7 +8,7 @@ .form-group = f.gitlab_ui_checkbox_component :throttle_unauthenticated_api_enabled, _("Enable unauthenticated API request rate limit"), - checkbox_options: { data: { qa_selector: 'throttle_unauthenticated_api_checkbox' } }, + checkbox_options: { data: { testid: 'throttle-unauthenticated-api-checkbox' } }, label_options: { class: 'label-bold' } .form-group = f.label :throttle_unauthenticated_api_requests_per_period, _('Maximum unauthenticated API requests per rate limit period per IP'), class: 'label-bold' @@ -21,7 +21,7 @@ .form-group = f.gitlab_ui_checkbox_component :throttle_unauthenticated_enabled, _("Enable unauthenticated web request rate limit"), - checkbox_options: { data: { qa_selector: 'throttle_unauthenticated_web_checkbox' } }, + checkbox_options: { data: { testid: 'throttle-unauthenticated-web-checkbox' } }, label_options: { class: 'label-bold' } .form-group = f.label :throttle_unauthenticated_requests_per_period, _('Maximum unauthenticated web requests per rate limit period per IP'), class: 'label-bold' @@ -34,7 +34,7 @@ .form-group = f.gitlab_ui_checkbox_component :throttle_authenticated_api_enabled, _("Enable authenticated API request rate limit"), - checkbox_options: { data: { qa_selector: 'throttle_authenticated_api_checkbox' }}, + checkbox_options: { data: { testid: 'throttle-authenticated-api-checkbox' }}, label_options: { class: 'label-bold' } .form-group = f.label :throttle_authenticated_api_requests_per_period, _('Maximum authenticated API requests per rate limit period per user'), class: 'label-bold' @@ -47,7 +47,7 @@ .form-group = f.gitlab_ui_checkbox_component :throttle_authenticated_web_enabled, _("Enable authenticated web request rate limit"), - checkbox_options: { data: { qa_selector: 'throttle_authenticated_web_checkbox' } }, + checkbox_options: { data: { testid: 'throttle-authenticated-web-checkbox' } }, label_options: { class: 'label-bold' } .form-group = f.label :throttle_authenticated_web_requests_per_period, _('Maximum authenticated web requests per rate limit period per user'), class: 'label-bold' @@ -57,6 +57,11 @@ = f.number_field :throttle_authenticated_web_period_in_seconds, class: 'form-control gl-form-input' %fieldset + .form-group + = f.label :project_jobs_api_rate_limit, safe_format('Maximum authenticated requests to %{open}project/:id/jobs%{close} per minute', tag_pair(tag.code, :open, :close)), class: 'label-bold' + = f.number_field :project_jobs_api_rate_limit, class: 'form-control gl-form-input' + + %fieldset %legend.h5.gl-border-none = _('Response text') .form-group @@ -66,4 +71,4 @@ .form-text.text-muted = html_escape(_("If blank, defaults to %{code_open}Retry later%{code_close}.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true + = f.submit _('Save changes'), data: { testid: 'save-changes-button' }, pajamas_button: true diff --git a/app/views/admin/application_settings/_jira_connect.html.haml b/app/views/admin/application_settings/_jira_connect.html.haml index 23ad85334cb..0a96268a0a3 100644 --- a/app/views/admin/application_settings/_jira_connect.html.haml +++ b/app/views/admin/application_settings/_jira_connect.html.haml @@ -9,10 +9,10 @@ %p.gl-text-secondary = s_('JiraConnect|Configure your Jira Connect Application ID.') = link_to sprite_icon('question-o'), - help_page_path('integration/jira/connect-app', + help_page_path('administration/settings/jira_cloud_app', aria: { label: _('GitLab for Jira Cloud') }, class: 'has-tooltip', - anchor: 'connect-the-gitlab-for-jira-cloud-app-for-self-managed-instances'), + anchor: 'connect-the-gitlab-for-jira-cloud-app'), title: _('More information') .settings-content diff --git a/app/views/admin/application_settings/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml index 4002aa076f7..25038e6f221 100644 --- a/app/views/admin/application_settings/_localization.html.haml +++ b/app/views/admin/application_settings/_localization.html.haml @@ -7,7 +7,7 @@ = f.select :first_day_of_week, first_day_of_week_choices, {}, class: 'form-control' .form-text.text-muted = _('Default first day of the week in calendars and date pickers.') - = link_to _('Learn more.'), help_page_path('administration/settings/index.md', anchor: 'change-the-default-first-day-of-the-week'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/localization.md', anchor: 'change-the-default-first-day-of-the-week'), target: '_blank', rel: 'noopener noreferrer' .form-group = f.label :time_tracking, _('Time tracking'), class: 'label-bold' diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml index 8cb25627dfa..f36fbd8d68c 100644 --- a/app/views/admin/application_settings/_outbound.html.haml +++ b/app/views/admin/application_settings/_outbound.html.haml @@ -15,7 +15,7 @@ = s_('OutboundRequests|Webhooks and integrations might not work properly.') = f.gitlab_ui_checkbox_component :allow_local_requests_from_web_hooks_and_services, s_('OutboundRequests|Allow requests to the local network from webhooks and integrations'), - checkbox_options: { disabled: deny_all_requests, class: 'js-allow-local-requests', data: { qa_selector: 'allow_requests_from_services_checkbox' } } + checkbox_options: { disabled: deny_all_requests, class: 'js-allow-local-requests', data: { testid: 'allow-requests-from-services-checkbox' } } = f.gitlab_ui_checkbox_component :allow_local_requests_from_system_hooks, s_('OutboundRequests|Allow requests to the local network from system hooks'), checkbox_options: { disabled: deny_all_requests, class: 'js-allow-local-requests' } @@ -33,4 +33,4 @@ s_('OutboundRequests|Enforce DNS-rebinding attack protection'), help_text: s_('OutboundRequests|Resolve IP addresses for outbound requests to prevent DNS-rebinding attacks.') - = f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), pajamas_button: true, data: { testid: '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 c09ba01b7ed..017fce3be47 100644 --- a/app/views/admin/application_settings/_performance_bar.html.haml +++ b/app/views/admin/application_settings/_performance_bar.html.haml @@ -5,9 +5,9 @@ .form-group = f.gitlab_ui_checkbox_component :performance_bar_enabled, _("Allow non-administrators access to the performance bar"), - checkbox_options: { data: { qa_selector: 'enable_performance_bar_checkbox' } } + checkbox_options: { data: { testid: 'enable-performance-bar-checkbox' } } .form-group = f.label :performance_bar_allowed_group_path, _('Allow access to members of the following group'), class: 'label-bold' = f.text_field :performance_bar_allowed_group_path, class: 'form-control gl-form-input', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path - = f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), pajamas_button: true, data: { testid: 'save-changes-button' } diff --git a/app/views/admin/application_settings/_sentry.html.haml b/app/views/admin/application_settings/_sentry.html.haml index 9f2a40e4e54..7058a4b5cca 100644 --- a/app/views/admin/application_settings/_sentry.html.haml +++ b/app/views/admin/application_settings/_sentry.html.haml @@ -1,28 +1,31 @@ = gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-sentry-settings'), html: { class: 'fieldset-form', id: 'sentry-settings' } do |f| = form_errors(@application_setting) - %span.text-muted - = _('Changing any setting here requires an application restart') + %fieldset.gl-text-secondary + = safe_format(s_('AdminSettings|GitLab uses the %{bold_start}Rails%{bold_end} and %{bold_start}Browser JavaScript%{bold_end} Sentry SDKs to send events to Sentry. For changes to Rails integration settings to take effect, restart GitLab.'), tag_pair(tag.b, :bold_start, :bold_end)) %fieldset .form-group - = f.gitlab_ui_checkbox_component :sentry_enabled, _('Enable Sentry error tracking') + = f.gitlab_ui_checkbox_component :sentry_enabled, s_('AdminSettings|Enable Sentry for Rails and Browser JavaScript') + .form-group + = f.label :sentry_environment, _('Environment'), class: 'label-light' + = f.text_field :sentry_environment, class: 'form-control gl-form-input', placeholder: Rails.env + .form-text.text-muted + = safe_format(s_('AdminSettings|%{setting_name} value used by both Rails and Browser JavaScript SDKs.'), setting_name: content_tag(:code, 'environment')) .form-group = f.label :sentry_dsn, _('DSN'), class: 'label-light' = f.text_field :sentry_dsn, class: 'form-control gl-form-input', placeholder: 'https://public@sentry.example.com/1' + .form-text.text-muted + = safe_format(s_('AdminSettings|%{setting_name} value used by the Rails SDK.'), setting_name: content_tag(:code, 'dsn')) .form-group = f.label :sentry_clientside_dsn, _('Clientside DSN'), class: 'label-light' = f.text_field :sentry_clientside_dsn, class: 'form-control gl-form-input', placeholder: 'https://public@sentry.example.com/2' - .form-group - = f.label :sentry_environment, _('Environment'), class: 'label-light' - = f.text_field :sentry_environment, class: 'form-control gl-form-input', placeholder: Rails.env - - %p.text-muted - = _("Changing any setting bellow doesn't require an application restart") - - %fieldset + .form-text.text-muted + = safe_format(s_('AdminSettings|%{setting_name} value used by the Browser JavaScript SDK.'), setting_name: content_tag(:code, 'dsn')) .form-group = f.label :sentry_clientside_traces_sample_rate, _('Clientside traces sample rate'), class: 'label-light' = f.number_field :sentry_clientside_traces_sample_rate, class: 'form-control gl-form-input', placeholder: '0.5', min: 0, max: 1, step: 0.001 + .form-text.text-muted + = safe_format(s_('AdminSettings|%{setting_name} value used by the Browser JavaScript SDK.'), setting_name: content_tag(:code, 'tracesSampleRate')) = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml index 1b90432e1f3..1049f42673d 100644 --- a/app/views/admin/application_settings/_snowplow.html.haml +++ b/app/views/admin/application_settings/_snowplow.html.haml @@ -1,12 +1,12 @@ - expanded = integration_expanded?('snowplow_') -%section.settings.as-snowplow.no-animate#js-snowplow-settings{ class: ('expanded' if expanded), data: { qa_selector: 'snowplow_settings_content' } } +%section.settings.as-snowplow.no-animate#js-snowplow-settings{ class: ('expanded' if expanded), data: { testid: 'snowplow-settings-content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Snowplow') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') %p.gl-text-secondary - - help_link = link_to('', help_page_path('development/internal_analytics/snowplow/index'), target: '_blank', rel: 'noopener noreferrer') + - help_link = link_to('', help_page_path('development/internal_analytics/internal_event_instrumentation/index'), target: '_blank', rel: 'noopener noreferrer') - snowplow_link = link_to('', 'https://snowplow.io/', target: '_blank', rel: 'noopener noreferrer') = safe_format(_('Configure %{snowplow_link_start}Snowplow%{snowplow_link_end} to track events. %{help_link_start}Learn more.%{help_link_end}'), tag_pair(snowplow_link, :snowplow_link_start, :snowplow_link_end), tag_pair(help_link, :help_link_start, :help_link_end)) .settings-content @@ -15,7 +15,7 @@ %fieldset .form-group - = f.gitlab_ui_checkbox_component :snowplow_enabled, _('Enable Snowplow tracking'), checkbox_options: { data: { qa_selector: 'snowplow_enabled_checkbox' } } + = f.gitlab_ui_checkbox_component :snowplow_enabled, _('Enable Snowplow tracking'), checkbox_options: { data: { testid: 'snowplow-enabled-checkbox' } } .form-group = f.label :snowplow_collector_hostname, _('Collector hostname'), class: 'label-light' = f.text_field :snowplow_collector_hostname, class: 'form-control gl-form-input', placeholder: 'snowplow.example.com' @@ -32,4 +32,4 @@ .form-text.text-muted = _('The Snowplow cookie domain.') - = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true + = f.submit _('Save changes'), data: { testid: 'save-changes-button' }, pajamas_button: true diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml index 5a3814ca83d..2d51dc2a6f2 100644 --- a/app/views/admin/application_settings/_usage.html.haml +++ b/app/views/admin/application_settings/_usage.html.haml @@ -18,7 +18,7 @@ - disabled_help_text = s_('AdminSettings|Service ping is disabled in your configuration file, and cannot be enabled through this form. For more information, see the documentation on %{link_start}deactivating service ping%{link_end}.').html_safe % { link_start: deactivating_service_ping_link_start, link_end: link_end } = f.gitlab_ui_checkbox_component :usage_ping_enabled, s_('AdminSettings|Enable Service Ping'), help_text: can_be_configured ? usage_ping_help_text : disabled_help_text, - checkbox_options: { disabled: !can_be_configured, data: { qa_selector: 'enable_usage_data_checkbox' } } + checkbox_options: { disabled: !can_be_configured, data: { testid: 'enable-usage-data-checkbox' } } .form-text.gl-pl-6 - if can_be_configured = render Pajamas::ButtonComponent.new(button_options: { class: 'js-payload-preview-trigger', data: { payload_selector: ".#{payload_class}" } }) do diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index 5aa2684f084..dad0bf08bb0 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -14,7 +14,7 @@ .settings-content = render 'visibility_and_access' -%section.settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'account_and_limit_settings_content', testid: 'account-limit' } } +%section.settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded_by_default?), data: { testid: 'account-and-limit-settings-content'} } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Account and limit') @@ -47,7 +47,7 @@ .settings-content = render 'diff_limits' -%section.settings.as-signup.no-animate#js-signup-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'sign_up_restrictions_settings_content' } } +%section.settings.as-signup.no-animate#js-signup-settings{ class: ('expanded' if expanded_by_default?), data: { testid: 'sign-up-restrictions-settings-content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Sign-up restrictions') 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 4739a204147..188359158ef 100644 --- a/app/views/admin/application_settings/metrics_and_profiling.html.haml +++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml @@ -29,7 +29,7 @@ .settings-content = render 'grafana' -%section.settings.as-performance-bar.no-animate#js-performance-bar-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'performance_bar_settings_content' } } +%section.settings.as-performance-bar.no-animate#js-performance-bar-settings{ class: ('expanded' if expanded_by_default?), data: { testid: 'performance-bar-settings-content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Profiling - Performance bar') diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml index 9ccfc6cbc0a..849c5c749e0 100644 --- a/app/views/admin/application_settings/network.html.haml +++ b/app/views/admin/application_settings/network.html.haml @@ -14,7 +14,7 @@ .settings-content = render 'performance' -%section.settings.as-ip-limits.no-animate#js-ip-limits-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'ip_limits_content' } } +%section.settings.as-ip-limits.no-animate#js-ip-limits-settings{ class: ('expanded' if expanded_by_default?), data: { testid: 'ip-limits-content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('User and IP rate limits') @@ -87,7 +87,7 @@ = render 'gitlab_shell_operation_limits' -%section.settings.as-outbound.no-animate#js-outbound-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'outbound_requests_content' } } +%section.settings.as-outbound.no-animate#js-outbound-settings{ class: ('expanded' if expanded_by_default?), data: { testid: 'outbound-requests-content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = s_('OutboundRequests|Outbound requests') diff --git a/app/views/admin/application_settings/preferences.html.haml b/app/views/admin/application_settings/preferences.html.haml index bea399ee926..4590b6f4586 100644 --- a/app/views/admin/application_settings/preferences.html.haml +++ b/app/views/admin/application_settings/preferences.html.haml @@ -3,7 +3,7 @@ - add_page_specific_style 'page_bundles/settings' - @force_desktop_expanded_sidebar = true -%section.settings.as-email.no-animate#js-email-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'email_content' } } +%section.settings.as-email.no-animate#js-email-settings{ class: ('expanded' if expanded_by_default?), data: { testid: 'email-content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Email') diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml index 27622dfa0bb..8d2e7366dcc 100644 --- a/app/views/admin/applications/_form.html.haml +++ b/app/views/admin/applications/_form.html.haml @@ -4,13 +4,13 @@ = content_tag :div, class: 'form-group row' do .col-12 = f.label :name - = f.text_field :name, class: 'form-control gl-form-input', data: { qa_selector: 'name_field' } + = f.text_field :name, class: 'form-control gl-form-input', data: { testid: 'name-field' } = doorkeeper_errors_for application, :name = content_tag :div, class: 'form-group row' do .col-12 = f.label :redirect_uri - = f.text_area :redirect_uri, class: 'form-control gl-form-input', data: { qa_selector: 'redirect_uri_field' } + = f.text_area :redirect_uri, class: 'form-control gl-form-input', data: { testid: 'redirect-uri-field' } = doorkeeper_errors_for application, :redirect_uri %span.form-text.text-muted Use one line per URI @@ -18,7 +18,7 @@ = content_tag :div, class: 'form-group row' do .col-12 = f.label :trusted - = f.gitlab_ui_checkbox_component :trusted, _('Trusted applications are automatically authorized on GitLab OAuth flow. It\'s highly recommended for the security of users that trusted applications have the confidential setting set to true.'), checkbox_options: { data: { qa_selector: 'trusted_checkbox' } } + = f.gitlab_ui_checkbox_component :trusted, _('Trusted applications are automatically authorized on GitLab OAuth flow. It\'s highly recommended for the security of users that trusted applications have the confidential setting set to true.'), checkbox_options: { data: { testid: 'trusted-checkbox' } } = content_tag :div, class: 'form-group row' do .col-12 @@ -31,5 +31,5 @@ = render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes, f: f .gl-mt-5 - = f.submit _('Save application'), pajamas_button: true, data: { qa_selector: 'save_application_button' } + = f.submit _('Save application'), pajamas_button: true, data: { testid: 'save-application-button' } = link_button_to _('Cancel'), admin_applications_path diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml index 6846fe8f4aa..07ccb8bb066 100644 --- a/app/views/admin/applications/index.html.haml +++ b/app/views/admin/applications/index.html.haml @@ -14,7 +14,7 @@ = s_('AdminArea|Manage applications for your instance that can use GitLab as an %{docs_link_start}OAuth provider%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe } .gl-new-card-actions - = render Pajamas::ButtonComponent.new(size: :small, href: new_admin_application_path, button_options: { data: { qa_selector: 'new_application_button' } }) do + = render Pajamas::ButtonComponent.new(size: :small, href: new_admin_application_path, button_options: { data: { testid: 'new-application-button' } }) do = _('Add new application') - c.with_body do - if @applications.empty? diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml index 728c748d01a..90859b5c170 100644 --- a/app/views/admin/health_check/show.html.haml +++ b/app/views/admin/health_check/show.html.haml @@ -2,12 +2,11 @@ - no_errors = @errors.blank? %h1.page-title.gl-font-size-h-display= page_title -.bs-callout.clearfix - .float-left - %p - #{ s_('HealthCheck|Access token is') } - %code#health-check-token= Gitlab::CurrentSettings.health_check_access_token - .gl-mt-3 += render Pajamas::AlertComponent.new(variant: :tip, dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c| + - c.with_body do + #{ s_('HealthCheck|Access token is') } + %code#health-check-token= Gitlab::CurrentSettings.health_check_access_token + - c.with_actions do = render Pajamas::ButtonComponent.new(href: reset_health_check_token_admin_application_settings_path, method: :put, button_options: { data: { confirm: _('Are you sure you want to reset the health check token?') } }) do = _("Reset health check access token") %p.light diff --git a/app/views/admin/identities/_form.html.haml b/app/views/admin/identities/_form.html.haml index ad78c677da1..dec35e9cf15 100644 --- a/app/views/admin/identities/_form.html.haml +++ b/app/views/admin/identities/_form.html.haml @@ -7,6 +7,7 @@ .col-sm-10 - values = Gitlab::Auth::OAuth::Provider.providers.map { |name| ["#{Gitlab::Auth::OAuth::Provider.label_for(name)} (#{name})", name] } = f.select :provider, values, { allow_blank: false }, class: 'form-control' + = render_if_exists partial: 'admin/identities/provider_id', locals: { f: f } .form-group.row .col-sm-2.col-form-label = f.label :extern_uid, _("Identifier") @@ -15,4 +16,3 @@ .form-actions = f.submit _('Save changes'), pajamas_button: true - diff --git a/app/views/admin/jobs/index.html.haml b/app/views/admin/jobs/index.html.haml index b8a9ad32259..9af5ffd7936 100644 --- a/app/views/admin/jobs/index.html.haml +++ b/app/views/admin/jobs/index.html.haml @@ -4,4 +4,4 @@ - page_title _("Jobs") -#admin-jobs-app{ data: { job_statuses: job_statuses.to_json, empty_state_svg_path: image_path('jobs-empty-state.svg'), url: cancel_all_admin_jobs_path } } +#admin-jobs-app{ data: { job_statuses: job_statuses.to_json, empty_state_svg_path: image_path('illustrations/empty-state/empty-pipeline-md.svg'), url: cancel_all_admin_jobs_path } } diff --git a/app/views/admin/labels/_label.html.haml b/app/views/admin/labels/_label.html.haml index 19460ddb0e5..c3857f4933d 100644 --- a/app/views/admin/labels/_label.html.haml +++ b/app/views/admin/labels/_label.html.haml @@ -1,4 +1,4 @@ -%li.label-list-item.gl-list-style-none.gl-py-3{ id: dom_id(label) } +%li.js-label-list-item.gl-list-style-none.gl-py-3.gl-border-b.gl-last-of-type-border-b-0{ id: dom_id(label) } .label-content.gl-px-3.gl-py-2.gl-rounded-base = render "shared/label_row", label: label.present(issuable_subject: nil) .label-actions-list.gl-display-inline-block diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml index 3d392a86566..b3d04e4f576 100644 --- a/app/views/admin/labels/index.html.haml +++ b/app/views/admin/labels/index.html.haml @@ -1,4 +1,5 @@ - page_title _("Labels") +- add_page_specific_style 'page_bundles/labels' = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card labels other-labels js-toggle-container js-admin-labels-container' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c| - c.with_header do diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml index 31ec4935f64..412d8e64e89 100644 --- a/app/views/admin/projects/index.html.haml +++ b/app/views/admin/projects/index.html.haml @@ -1,5 +1,6 @@ - page_title _('Projects') - add_page_specific_style 'page_bundles/search' +- add_page_specific_style 'page_bundles/projects' - params[:visibility_level] ||= [] .top-area.gl-flex-direction-column-reverse diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index 85dce00752b..6be5aa003fc 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -1,4 +1,5 @@ - add_page_specific_style 'page_bundles/members' +- add_page_specific_style 'page_bundles/projects' - add_to_breadcrumbs _("Projects"), admin_projects_path - breadcrumb_title @project.full_name - page_title @project.full_name, _("Projects") diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml index f880c2631ed..35b75e425f9 100644 --- a/app/views/admin/sessions/_new_base.html.haml +++ b/app/views/admin/sessions/_new_base.html.haml @@ -1,7 +1,7 @@ = gitlab_ui_form_for(:user, url: admin_session_path, html: { class: 'gl-p-5 gl-show-field-errors', aria: { live: 'assertive' } }) do |f| .form-group = f.label :password, _('Password') - = f.password_field :password, class: 'form-control js-password', data: { id: 'user_password', name: 'user[password]', qa_selector: 'password_field', testid: 'password-field' } + = f.password_field :password, class: 'form-control js-password', data: { id: 'user_password', name: 'user[password]', testid: 'password-field' } - = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { qa_selector: 'enter_admin_mode_button' } }) do + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { testid: 'enter-admin-mode-button' } }) do = _('Enter admin mode') diff --git a/app/views/admin/users/_profile.html.haml b/app/views/admin/users/_profile.html.haml index bb89b5baf28..df0a59ccfc3 100644 --- a/app/views/admin/users/_profile.html.haml +++ b/app/views/admin/users/_profile.html.haml @@ -1,4 +1,4 @@ -= render Pajamas::CardComponent.new(body_options: { class: 'gl-py-0' }) do |c| += render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-py-0' }) do |c| - c.with_header do = _('Profile') - c.with_body do diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 4cc3e12a8ad..bee7e10906b 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -69,6 +69,7 @@ = @user.external? ? _('Yes') : _('No') = render_if_exists 'admin/users/provisioned_by', user: @user + = render_if_exists 'admin/users/enterprise_group', user: @user %li %span.light= _('Can create top level groups:') diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml index 5062599c261..fd4801b7941 100644 --- a/app/views/award_emoji/_awards_block.html.haml +++ b/app/views/award_emoji/_awards_block.html.haml @@ -1,8 +1,9 @@ - api_awards_path = local_assigns.fetch(:api_awards_path, nil) +- new_custom_emoji_path = local_assigns.fetch(:new_custom_emoji_path, nil) - if api_awards_path .gl-display-flex.gl-flex-wrap.gl-justify-content-space-between.gl-pt-3 - #js-vue-awards-block{ data: { path: api_awards_path, can_award_emoji: can?(current_user, :award_emoji, awardable).to_s } } + #js-vue-awards-block{ data: { path: api_awards_path, can_award_emoji: can?(current_user, :award_emoji, awardable).to_s, new_custom_emoji_path: new_custom_emoji_path, show_default_award_emojis: @project&.show_default_award_emojis?.to_s } } = yield - else - grouped_emojis = awardable.grouped_awards(with_thumbs: inline) diff --git a/app/views/ci/status/_icon.html.haml b/app/views/ci/status/_icon.html.haml index fdaacb732c7..9fa5734d6b6 100644 --- a/app/views/ci/status/_icon.html.haml +++ b/app/views/ci/status/_icon.html.haml @@ -1,14 +1,10 @@ - status = local_assigns.fetch(:status) -- size = local_assigns.fetch(:size, 16) - tooltip_placement = local_assigns.fetch(:tooltip_placement, "left") - path = local_assigns.fetch(:path, status.has_details? ? status.details_path : nil) - option_css_classes = local_assigns.fetch(:option_css_classes, '') -- css_classes = "ci-status-link ci-status-icon ci-status-icon-#{status.group} has-tooltip #{option_css_classes}" -- title = s_("PipelineStatusTooltip|Pipeline: %{ci_status}") % {ci_status: status.label} +- css_classes = "gl-px-2 #{option_css_classes}" +- ci_css_classes = "ci-status-link ci-status-icon ci-status-icon-#{status.group} gl-line-height-1" +- title = s_("PipelineStatusTooltip|Pipeline: %{ci_status}") % {ci_status: status.label} -- if path - = link_to path, class: css_classes, title: title, data: { placement: tooltip_placement } do - = sprite_icon(status.icon, size: size) -- else - %span{ class: css_classes, title: title, data: { placement: tooltip_placement } } - = sprite_icon(status.icon, size: size) += gl_badge_tag(variant: badge_variant(status), size: :md, href: path, class: css_classes, title: title, data: { toggle: 'tooltip', placement: tooltip_placement, testid: "ci-status-badge" }) do + = content_tag :span, sprite_icon(status.icon, size: 16), class: ci_css_classes diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml index b72b252a852..74dc2277f54 100644 --- a/app/views/dashboard/_projects_head.html.haml +++ b/app/views/dashboard/_projects_head.html.haml @@ -7,7 +7,7 @@ .page-title-controls.gl-display-flex.gl-align-items-center.gl-gap-5 = link_to _("Explore projects"), explore_projects_path - if current_user.can_create_project? - = render Pajamas::ButtonComponent.new(href: new_project_path, variant: :confirm, button_options: { data: { qa_selector: 'new_project_button' } }) do + = render Pajamas::ButtonComponent.new(href: new_project_path, variant: :confirm, button_options: { data: { testid: 'new-project-button' } }) do = _("New project") .top-area diff --git a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml index 94f956896d6..1d2e6e1e332 100644 --- a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml +++ b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml @@ -4,7 +4,7 @@ - if has_start_trial? = render_if_exists "dashboard/projects/blank_state_ee_trial" - = link_to new_project_path, class: link_classes, data: { qa_selector: 'new_project_button' } do + = link_to new_project_path, class: link_classes, data: { testid: 'new-project-button' } do .blank-state-icon = custom_icon("add_new_project", size: 50) .blank-state-body.gl-sm-pl-6 diff --git a/app/views/dashboard/projects/_blank_state_welcome.html.haml b/app/views/dashboard/projects/_blank_state_welcome.html.haml index 08b914a218d..032c5206d99 100644 --- a/app/views/dashboard/projects/_blank_state_welcome.html.haml +++ b/app/views/dashboard/projects/_blank_state_welcome.html.haml @@ -2,7 +2,7 @@ .gl-display-flex.gl-flex-wrap.gl-justify-content-space-between - if current_user.can_create_project? - = link_to new_project_path, class: link_classes, data: { qa_selector: 'new_project_button' } do + = link_to new_project_path, class: link_classes, data: { testid: 'new-project-button' } do .blank-state-icon = custom_icon("add_new_project", size: 50) .blank-state-body.gl-sm-pl-6 diff --git a/app/views/dashboard/projects/_zero_authorized_projects.html.haml b/app/views/dashboard/projects/_zero_authorized_projects.html.haml index e72762f2ae5..da25dee1e88 100644 --- a/app/views/dashboard/projects/_zero_authorized_projects.html.haml +++ b/app/views/dashboard/projects/_zero_authorized_projects.html.haml @@ -1,6 +1,6 @@ .container .gl-text-center.gl-pt-6.gl-pb-7 - %h2.gl-font-size-h1{ data: { qa_selector: 'welcome_title_content' } } + %h2.gl-font-size-h1{ data: { testid: 'welcome-title-content' } } = _('Welcome to GitLab') %p.gl-m-0 = _('Faster releases. Better code. Less pain.') diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml index 140bc6e06c3..c74a9f4cbe6 100644 --- a/app/views/dashboard/projects/index.html.haml +++ b/app/views/dashboard/projects/index.html.haml @@ -5,6 +5,7 @@ - page_title _("Projects") - add_page_specific_style 'page_bundles/dashboard_projects' +- add_page_specific_style 'page_bundles/projects' = render "projects/last_push" - if show_projects?(@projects, params) diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index 3feb30085c0..ab97507b3c8 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -5,6 +5,9 @@ = render_if_exists 'dashboard/todos/saml_reauth_notice' - add_page_specific_style 'page_bundles/todos' - add_page_specific_style 'page_bundles/issuable' +- filter_by_done = params[:state] == 'done' +- open_todo_count = todos_has_filtered_results? && !filter_by_done ? @allowed_todos.count : todos_pending_count +- done_todo_count = todos_has_filtered_results? && filter_by_done ? @allowed_todos.count : todos_done_count .page-title-holder.d-flex.align-items-center %h1.page-title.gl-font-size-h-display= _("To-Do List") @@ -14,10 +17,10 @@ = gl_tabs_nav({ class: 'gl-flex-grow-1 gl-border-0' }) do = gl_tab_link_to todos_filter_path(state: 'pending'), item_active: params[:state].blank? || params[:state] == 'pending', class: "js-todos-pending" do = _("To Do") - = gl_tab_counter_badge(number_with_delimiter(todos_pending_count), { class: 'js-todos-badge' }) - = gl_tab_link_to todos_filter_path(state: 'done'), item_active: params[:state] == 'done', class: "js-todos-done" do + = gl_tab_counter_badge(number_with_delimiter(open_todo_count), { class: 'js-todos-badge' }) + = gl_tab_link_to todos_filter_path(state: 'done'), item_active: filter_by_done, class: "js-todos-done" do = _("Done") - = gl_tab_counter_badge(number_with_delimiter(todos_done_count), { class: 'js-todos-badge' }) + = gl_tab_counter_badge(number_with_delimiter(done_todo_count), { class: 'js-todos-badge' }) .nav-controls - if @allowed_todos.any?(&:pending?) @@ -80,31 +83,38 @@ %ul.content-list.todos-list = render @allowed_todos = paginate @todos, theme: "gitlab" - .js-nothing-here-container.empty-state.hidden + .js-nothing-here-container.gl-empty-state.gl-text-center.hidden .svg-content.svg-150 = image_tag 'illustrations/empty-todos-all-done-md.svg' .text-content.gl-text-center - %h4 + %h1.gl-font-size-h-display.gl-line-height-36.gl-mt-0 = s_("Todos|You're all done!") - elsif current_user.todos.any? - .col.todos-all-done.empty-state + .col.todos-all-done.gl-empty-state.gl-text-center .svg-content.svg-150 - = image_tag 'illustrations/empty-todos-all-done-md.svg' - .text-content.gl-text-center - - if todos_filter_empty? - %h4 + = image_tag (!todos_filter_empty? && !todos_has_filtered_results?) ? 'illustrations/empty-todos-all-done-md.svg' : 'illustrations/empty-todos-md.svg' + .text-content.gl-text-center.gl-m-auto{ class: "gl-max-w-88!" } + %h1.gl-font-size-h-display.gl-line-height-36.gl-mt-0 + - if todos_filter_empty? = no_todos_messages.sample + - elsif todos_has_filtered_results? + = _("Sorry, your filter produced no results") + - else + = s_("Todos|Nothing is on your to-do list. Nice work!") + + - if todos_filter_empty? %p = (s_("Todos|Are you looking for things to do? Take a look at %{strongStart}%{openIssuesLinkStart}open issues%{openIssuesLinkEnd}%{strongEnd}, contribute to %{strongStart}%{mergeRequestLinkStart}a merge request%{mergeRequestLinkEnd}%{mergeRequestLinkEnd}%{strongEnd}, or mention someone in a comment to automatically assign them a new to-do item.") % { strongStart: '<strong>', strongEnd: '</strong>', openIssuesLinkStart: "<a href=\"#{issues_dashboard_path}\">", openIssuesLinkEnd: '</a>', mergeRequestLinkStart: "<a href=\"#{merge_requests_dashboard_path}\">", mergeRequestLinkEnd: '</a>' }).html_safe - - else - %h4 - = s_("Todos|Nothing is on your to-do list. Nice work!") + - elsif todos_has_filtered_results? + %p + = link_to s_("Todos|Do you want to remove the filters?"), todos_filter_path(without: [:project_id, :author_id, :type, :action_id]) + - else - .col.empty-state + .col.gl-empty-state.gl-text-center .svg-content.svg-150 = image_tag 'illustrations/empty-todos-md.svg' - .text-content.gl-text-center - %h4 + .text-content.gl-text-center.gl-m-auto{ class: "gl-max-w-88!" } + %h1.gl-font-size-h-display.gl-line-height-36.gl-mt-0 = s_("Todos|Your To-Do List shows what to work on next") %p = (s_("Todos|When an issue or merge request is assigned to you, or when you receive a %{strongStart}@mention%{strongEnd} in a comment, this automatically triggers a new item in your To-Do List.") % { strongStart: '<strong>', strongEnd: '</strong>' }).html_safe diff --git a/app/views/devise/confirmations/almost_there.haml b/app/views/devise/confirmations/almost_there.haml index 1760e6e0f84..8075697b758 100644 --- a/app/views/devise/confirmations/almost_there.haml +++ b/app/views/devise/confirmations/almost_there.haml @@ -3,10 +3,10 @@ - registration_link_start = '<a href="%{new_user_registration_path}">'.html_safe % { new_user_registration_path: new_user_registration_path } - link_end = '</a>'.html_safe - content_for :page_specific_javascripts do - = render "layouts/google_tag_manager_head" + = render_if_exists "layouts/google_tag_manager_head" = render "layouts/one_trust" = render "layouts/bizible" -= render "layouts/google_tag_manager_body" += render_if_exists "layouts/google_tag_manager_body" = render_if_exists 'devise/shared/delete_unconfirmed_users_flash' diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml index 18856e04eb8..29f1a1f398b 100644 --- a/app/views/devise/registrations/new.html.haml +++ b/app/views/devise/registrations/new.html.haml @@ -2,10 +2,10 @@ - page_description _("Join GitLab today! You and your team can plan, build, and ship secure code all in one application. Get started here for free!") - add_page_specific_style 'page_bundles/signup' - content_for :page_specific_javascripts do - = render "layouts/google_tag_manager_head" + = render_if_exists "layouts/google_tag_manager_head" = render "layouts/one_trust" = render "layouts/bizible" -= render "layouts/google_tag_manager_body" += render_if_exists "layouts/google_tag_manager_body" - content_for :omniauth_providers_bottom do = render 'devise/shared/signup_omniauth_providers' diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml index e6551adffde..88dd4fd1721 100644 --- a/app/views/devise/sessions/_new_base.html.haml +++ b/app/views/devise/sessions/_new_base.html.haml @@ -15,14 +15,12 @@ = link_to _('Forgot your password?'), new_password_path(:user) .form-group - - if Feature.enabled?(:arkose_labs_login_challenge) - = render_if_exists 'devise/sessions/arkose_labs' - - elsif captcha_enabled? || captcha_on_login_required? + - if captcha_enabled? || captcha_on_login_required? = recaptcha_tags nonce: content_security_policy_nonce - if remember_me_enabled? .form-group = f.gitlab_ui_checkbox_component :remember_me, _('Remember me'), checkbox_options: { autocomplete: 'off' } - = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { class: "js-sign-in-button #{'js-no-auto-disable' if Feature.enabled?(:arkose_labs_login_challenge)}", data: { qa_selector: 'sign_in_button', testid: 'sign-in-button' } }) do + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { class: 'js-sign-in-button', data: { qa_selector: 'sign_in_button', testid: 'sign-in-button' } }) do = _('Sign in') diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml index 0fd27f7f7e7..acfb16b64cd 100644 --- a/app/views/devise/sessions/new.html.haml +++ b/app/views/devise/sessions/new.html.haml @@ -1,13 +1,13 @@ - page_title _("Sign in") - content_for :page_specific_javascripts do - = render "layouts/google_tag_manager_head" + = render_if_exists "layouts/google_tag_manager_head" = render "layouts/one_trust" - content_for :sessions_broadcast do = render "devise/sessions/broadcast" -= render "layouts/google_tag_manager_body" += render_if_exists "layouts/google_tag_manager_body" #signin-container - if any_form_based_providers_enabled? diff --git a/app/views/devise/shared/_signup_omniauth_providers.haml b/app/views/devise/shared/_signup_omniauth_providers.haml index 399c23741a9..4e62c10b258 100644 --- a/app/views/devise/shared/_signup_omniauth_providers.haml +++ b/app/views/devise/shared/_signup_omniauth_providers.haml @@ -3,4 +3,4 @@ = _("or") = render 'devise/shared/signup_omniauth_provider_list', providers: enabled_button_based_providers, - tracking_label: ::Onboarding::Status.tracking_label[:free] + tracking_label: oauth_tracking_label diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index ca7798257cb..544acd5ae56 100644 --- a/app/views/groups/_home_panel.html.haml +++ b/app/views/groups/_home_panel.html.haml @@ -35,7 +35,7 @@ - if can_create_projects .gl-sm-w-auto.gl-w-full - = render Pajamas::ButtonComponent.new(href: new_project_path(namespace_id: @group.id), variant: :confirm, button_options: { data: { qa_selector: 'new_project_button' }, class: 'gl-sm-w-auto gl-w-full' }) do + = render Pajamas::ButtonComponent.new(href: new_project_path(namespace_id: @group.id), variant: :confirm, button_options: { data: { testid: 'new-project-button' }, class: 'gl-sm-w-auto gl-w-full' }) do = _('New project') - if @group.description.present? diff --git a/app/views/groups/custom_emoji/index.html.haml b/app/views/groups/custom_emoji/index.html.haml new file mode 100644 index 00000000000..d8c28ac8959 --- /dev/null +++ b/app/views/groups/custom_emoji/index.html.haml @@ -0,0 +1,8 @@ +- page_title _('Custom emoji') + +#js-custom-emojis-root.row.gl-mt-5{ data: { base_path: group_custom_emoji_index_path(@group), group_path: @group.full_path } } + .col-12 + %h4.gl-mt-0 + = page_title + %p= _('Custom emoji will be available to use in every project in the group.') + = gl_loading_icon(size: 'lg') diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 8d6eebc27b0..b2ea15d0e47 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -49,7 +49,7 @@ = render_if_exists 'groups/templates_setting', expanded: expanded = render_if_exists 'shared/groups/max_pages_size_setting' -%section.settings.gs-advanced.no-animate#js-advanced-settings{ class: ('expanded' if expanded), data: { qa_selector: 'advanced_settings_content' } } +%section.settings.gs-advanced.no-animate#js-advanced-settings{ class: ('expanded' if expanded), data: { testid: 'advanced-settings-content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' } = _('Advanced') diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml index 6b4832d81aa..e174d6318e9 100644 --- a/app/views/groups/labels/index.html.haml +++ b/app/views/groups/labels/index.html.haml @@ -3,6 +3,7 @@ - search = params[:search] - subscribed = params[:subscribed] - labels_or_filters = @labels.exists? || search.present? || subscribed.present? +- add_page_specific_style 'page_bundles/labels' - if labels_or_filters #js-promote-label-modal diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml index 7665da08582..ea36aae1c2f 100644 --- a/app/views/groups/milestones/_form.html.haml +++ b/app/views/groups/milestones/_form.html.haml @@ -6,7 +6,7 @@ .form-group = f.label :title, _("Title") - = f.text_field :title, maxlength: 255, class: "form-control", data: { qa_selector: "milestone_title_field" }, required: true, autofocus: true + = f.text_field :title, maxlength: 255, class: "form-control", data: { testid: "milestone-title-field" }, required: true, autofocus: true = render "shared/milestones/form_dates", f: f .form-group = f.label :description, _("Description") @@ -26,7 +26,7 @@ = f.hidden_field :lock_version - if @milestone.new_record? - = f.submit _('Create milestone'), data: { qa_selector: "create_milestone_button" }, class: 'gl-mr-2', pajamas_button: true + = f.submit _('Create milestone'), data: { testid: "create-milestone-button" }, class: 'gl-mr-2', pajamas_button: true = render Pajamas::ButtonComponent.new(href: group_milestones_path(@group)) do = _("Cancel") - else diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml index f49b69f821d..0235e252a9d 100644 --- a/app/views/groups/milestones/index.html.haml +++ b/app/views/groups/milestones/index.html.haml @@ -9,13 +9,13 @@ = render 'shared/milestones/search_form' = render 'shared/milestones_sort_dropdown' - if can?(current_user, :admin_milestone, @group) - = 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 + = render Pajamas::ButtonComponent.new(href: new_group_milestone_path(@group), variant: :confirm, button_options: { data: { testid: "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 - = render Pajamas::ButtonComponent.new(href: new_group_milestone_path(@group), variant: :confirm, button_options: { data: { qa_selector: "new_group_milestone_link" }}) do + = render Pajamas::ButtonComponent.new(href: new_group_milestone_path(@group), variant: :confirm, button_options: { data: { testid: "new-group-milestone-link" }}) do = _('New milestone') - else .milestones @@ -30,5 +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 - = render Pajamas::ButtonComponent.new(href: new_group_milestone_path(@group), variant: :confirm, button_options: { data: { qa_selector: "new_group_milestone_link" }}) do + = render Pajamas::ButtonComponent.new(href: new_group_milestone_path(@group), variant: :confirm, button_options: { data: { testid: "new-group-milestone-link" }}) do = _('New milestone') diff --git a/app/views/groups/observability/observability.html.haml b/app/views/groups/observability/observability.html.haml deleted file mode 100644 index 834fa0e027c..00000000000 --- a/app/views/groups/observability/observability.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -- page_title observability_page_title - -#js-observability-app{ data: { observability_iframe_src: observability_iframe_src(@group) } } diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml index 22e9f9f5071..76758769d01 100644 --- a/app/views/groups/projects.html.haml +++ b/app/views/groups/projects.html.haml @@ -1,5 +1,6 @@ - breadcrumb_title _("Projects") - page_title _("Projects") +- add_page_specific_style 'page_bundles/projects' - @force_desktop_expanded_sidebar = true = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-search-settings-section' }, header_options: { class: 'gl-new-card-header gl-display-flex' }, body_options: { class: 'gl-new-card-body' }) do |c| diff --git a/app/views/groups/settings/_git_access_protocols.html.haml b/app/views/groups/settings/_git_access_protocols.html.haml index db177da1d84..d23f72a3055 100644 --- a/app/views/groups/settings/_git_access_protocols.html.haml +++ b/app/views/groups/settings/_git_access_protocols.html.haml @@ -1,7 +1,7 @@ - if group.root? .form-group = 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? + = f.select :enabled_git_access_protocol, options_for_select(enabled_git_access_protocol_options_for_group(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 = _("This setting has been configured at the instance level and cannot be overridden per group") diff --git a/app/views/groups/settings/_transfer.html.haml b/app/views/groups/settings/_transfer.html.haml index 368e4a981bc..aa533d54b1a 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_full_path: @group.full_path, group_name: @group.name, group_id: @group.id, target_form_id: form_id, is_paid_group: group.paid?.to_s } -= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card', data: { qa_selector: 'transfer_group_content' } }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| += render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card', data: { testid: 'transfer-group-content' } }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| - c.with_header do .gl-new-card-title-wrapper %h4.gl-new-card-title.warning-title= s_('GroupSettings|Transfer group') diff --git a/app/views/groups/settings/access_tokens/index.html.haml b/app/views/groups/settings/access_tokens/index.html.haml index ef85eab6778..832fd6e8ceb 100644 --- a/app/views/groups/settings/access_tokens/index.html.haml +++ b/app/views/groups/settings/access_tokens/index.html.haml @@ -50,6 +50,7 @@ access_levels: GroupMember.access_level_roles, default_access_level: Gitlab::Access::GUEST, prefix: :resource_access_token, + description_prefix: :group_access_token, help_path: help_page_path('user/group/settings/group_access_tokens', anchor: 'scopes-for-a-group-access-token') #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This group has no active access tokens.'), show_role: true } } diff --git a/app/views/groups/work_items/show.html.haml b/app/views/groups/work_items/show.html.haml index eb962cc0b69..7def8b0d6e6 100644 --- a/app/views/groups/work_items/show.html.haml +++ b/app/views/groups/work_items/show.html.haml @@ -1 +1,7 @@ -.h1 Work Item +- page_title "##{request.params['iid']}" +- add_to_breadcrumbs _("Issues"), issues_group_path(@group) +- add_page_specific_style 'page_bundles/work_items' +- @gfm_form = true +- @noteable_type = 'WorkItem' + +#js-work-items{ data: work_items_index_data(@group).merge(iid: request.params['iid']) } diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml index b07374e5b5f..6f25bc75ca1 100644 --- a/app/views/import/github/status.html.haml +++ b/app/views/import/github/status.html.haml @@ -5,10 +5,8 @@ = sprite_icon('github', css_class: 'gl-mr-2') = _('Import repositories from GitHub') -- paginatable = Feature.enabled?(:remove_legacy_github_client) - = render 'import/githubish_status', - provider: 'github', paginatable: paginatable, + provider: 'github', paginatable: true, default_namespace: @namespace, cancel_path: cancel_import_github_path, details_path: details_import_github_path, diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml index 5a558d42802..c08abfeb813 100644 --- a/app/views/layouts/_flash.html.haml +++ b/app/views/layouts/_flash.html.haml @@ -15,3 +15,4 @@ - elsif value = render Pajamas::AlertComponent.new(variant: type_to_variant[key], dismissible: closable.include?(key), alert_options: {class: "flash-#{key}", data: { testid: "alert-#{type_to_variant[key]}" }}) do |c| = c.with_body { value } + #js-global-alerts diff --git a/app/views/layouts/_google_tag_manager_body.html.haml b/app/views/layouts/_google_tag_manager_body.html.haml deleted file mode 100644 index 98d7bf5d138..00000000000 --- a/app/views/layouts/_google_tag_manager_body.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -- return unless google_tag_manager_enabled? - -<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=#{google_tag_manager_id}" -height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript> diff --git a/app/views/layouts/_google_tag_manager_head.html.haml b/app/views/layouts/_google_tag_manager_head.html.haml deleted file mode 100644 index 711a3d66ff7..00000000000 --- a/app/views/layouts/_google_tag_manager_head.html.haml +++ /dev/null @@ -1,51 +0,0 @@ -- return unless google_tag_manager_enabled? - -- if Feature.enabled?(:gitlab_gtm_datalayer, type: :ops) - = javascript_tag do - :plain - window.dataLayer = window.dataLayer || []; - function gtag(){dataLayer.push(arguments);} - - gtag('consent', 'default', { - 'analytics_storage': 'granted', - 'ad_storage': 'granted', - 'functionality_storage': 'granted', - 'wait_for_update': 500 - }); - - gtag('consent', 'default', { - 'analytics_storage': 'denied', - 'ad_storage': 'denied', - 'functionality_storage': 'denied', - 'region': ['AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'IS', 'LI', 'NO', 'GB', 'PE', 'RU'], - '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 - (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': - new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], - j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= - 'https://www.googletagmanager.com/gtm.js?id='+i+dl;j.setAttribute('nonce', - '#{content_security_policy_nonce}');f.parentNode.insertBefore(j,f); - })(window,document,'script','dataLayer','#{google_tag_manager_id}'); -- else - = javascript_tag do - :plain - (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': - new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], - j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= - 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f); - })(window,document,'script','dataLayer','#{google_tag_manager_id}'); diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index bbde5f2843b..37d03bde72e 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -1,6 +1,11 @@ - page_description brand_title unless page_description - site_name = _('GitLab') - omit_og = sign_in_with_redirect? + +-# This is a temporary place for the page specific style migrations to be included on all pages like page_specific_files +- if Feature.disabled?(:page_specific_styles, current_user) + - add_page_specific_style('page_bundles/projects') + %head{ omit_og ? { } : { prefix: "og: http://ogp.me/ns#" } } %meta{ charset: "utf-8" } %meta{ 'http-equiv' => 'X-UA-Compatible', content: 'IE=edge' } @@ -38,6 +43,7 @@ = stylesheet_link_tag_defer "highlight/themes/#{user_color_scheme}" = stylesheet_link_tag 'performance_bar' if performance_bar_enabled? + = render 'layouts/snowplow' = render 'layouts/loading_hints' diff --git a/app/views/layouts/_img_loader.html.haml b/app/views/layouts/_img_loader.html.haml index c1fe3ae0924..ac00a18f0bc 100644 --- a/app/views/layouts/_img_loader.html.haml +++ b/app/views/layouts/_img_loader.html.haml @@ -13,6 +13,6 @@ img.removeAttribute('data-src'); img.classList.remove('lazy'); img.classList.add('js-lazy-loaded'); - img.dataset.testid = 'js_lazy_loaded_content'; + img.dataset.testid = 'js-lazy-loaded-content'; }); } diff --git a/app/views/layouts/_loading_hints.html.haml b/app/views/layouts/_loading_hints.html.haml index 1e6f671aacb..bf0421e9624 100644 --- a/app/views/layouts/_loading_hints.html.haml +++ b/app/views/layouts/_loading_hints.html.haml @@ -17,6 +17,7 @@ -# 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('gitlab-sans/GitLabSans-Italic.woff2'), as: 'font', crossorigin: css_crossorigin } %link{ rel: 'preload', href: font_path('gitlab-mono/GitLabMono.woff2'), as: 'font', crossorigin: css_crossorigin } %link{ rel: 'preload', href: font_path('gitlab-mono/GitLabMono-Italic.woff2'), as: 'font', crossorigin: css_crossorigin } = preload_link_tag(path_to_stylesheet('fonts'), crossorigin: css_crossorigin) diff --git a/app/views/layouts/_snowplow.html.haml b/app/views/layouts/_snowplow.html.haml index 10b2002dfef..3582deea902 100644 --- a/app/views/layouts/_snowplow.html.haml +++ b/app/views/layouts/_snowplow.html.haml @@ -2,7 +2,7 @@ - namespace = @group || @project&.namespace || @namespace = webpack_bundle_tag 'tracker' -- if Gitlab.com? && Feature.enabled?(:browsersdk_tracking) +- if Gitlab.com? && Feature.enabled?(:browsersdk_tracking) && Feature.enabled?(:gl_analytics_tracking, Feature.current_request) = webpack_bundle_tag 'analytics' = javascript_tag do :plain diff --git a/app/views/layouts/component_preview.html.haml b/app/views/layouts/component_preview.html.haml index 8217ac13c52..82ad5e3e3bb 100644 --- a/app/views/layouts/component_preview.html.haml +++ b/app/views/layouts/component_preview.html.haml @@ -1,10 +1,12 @@ %head + = stylesheet_link_tag "fonts" - if params[:lookbook][:display][:theme] == "light" = stylesheet_link_tag "application" = stylesheet_link_tag "application_utilities" - else = stylesheet_link_tag "application_dark" = stylesheet_link_tag "application_utilities_dark" + %body .gl-mt-6{ class: (params[:lookbook][:display][:layout] == "fluid" ? "container-fluid" : "container") } - if params[:lookbook][:display][:bg_dark] diff --git a/app/views/layouts/header/_super_sidebar_logged_out.haml b/app/views/layouts/header/_super_sidebar_logged_out.haml index 31dfdfb2bb3..76c7ea03c2a 100644 --- a/app/views/layouts/header/_super_sidebar_logged_out.haml +++ b/app/views/layouts/header/_super_sidebar_logged_out.haml @@ -1,7 +1,7 @@ %header.navbar.navbar-gitlab.super-sidebar-logged-out{ data: { testid: 'navbar' } } %a.gl-sr-only.gl-accessibility{ href: "#content-body" } Skip to content .container-fluid - .header-content.gl-displax-flex + %nav.header-content.gl-displax-flex{ 'aria-label': s_('LoggedOutMarketingHeader|Explore GitLab') } .title-container.gl-display-flex.gl-align-items-stretch.gl-pt-0.gl-mr-3 = render 'layouts/header/title' diff --git a/app/views/layouts/header/_title.html.haml b/app/views/layouts/header/_title.html.haml index 0e57c6809c2..59141cfa2a3 100644 --- a/app/views/layouts/header/_title.html.haml +++ b/app/views/layouts/header/_title.html.haml @@ -1,6 +1,6 @@ .title %span.gl-sr-only GitLab - = link_to root_path, title: _('Homepage'), id: 'logo', class: 'has-tooltip', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation_top') do + = link_to root_path, title: _('Homepage'), id: 'logo', class: 'has-tooltip', aria: { label: _('Homepage') }, **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation_top') do = brand_header_logo .gl-display-flex.gl-align-items-center - if Gitlab.com_and_canary? diff --git a/app/views/layouts/mailer/_user_deactivated_additional_text.html.haml b/app/views/layouts/mailer/_user_deactivated_additional_text.html.haml index cabdd4b09ec..5104d4f6e11 100644 --- a/app/views/layouts/mailer/_user_deactivated_additional_text.html.haml +++ b/app/views/layouts/mailer/_user_deactivated_additional_text.html.haml @@ -1,3 +1,3 @@ -- return unless Feature.enabled?(:deactivation_email_additional_text) && Gitlab::CurrentSettings.deactivation_email_additional_text.present? +- return unless Gitlab::CurrentSettings.deactivation_email_additional_text.present? %p = Gitlab::Utils.nlbr(Gitlab::CurrentSettings.deactivation_email_additional_text) diff --git a/app/views/layouts/mailer/_user_deactivated_additional_text.text.erb b/app/views/layouts/mailer/_user_deactivated_additional_text.text.erb index 5862c8059f9..0de59fe7a75 100644 --- a/app/views/layouts/mailer/_user_deactivated_additional_text.text.erb +++ b/app/views/layouts/mailer/_user_deactivated_additional_text.text.erb @@ -1,3 +1,3 @@ -<% return unless Feature.enabled?(:deactivation_email_additional_text) && Gitlab::CurrentSettings.deactivation_email_additional_text.present? %> +<% return unless Gitlab::CurrentSettings.deactivation_email_additional_text.present? %> <%= Gitlab::CurrentSettings.deactivation_email_additional_text %> diff --git a/app/views/layouts/nav/_ask_duo_button.html.haml b/app/views/layouts/nav/_ask_duo_button.html.haml index f17ccfc8afe..e37ce50352c 100644 --- a/app/views/layouts/nav/_ask_duo_button.html.haml +++ b/app/views/layouts/nav/_ask_duo_button.html.haml @@ -1,5 +1,5 @@ - if Gitlab.ee? && ::Gitlab::Llm::TanukiBot.show_breadcrumbs_entry_point_for?(user: current_user) - - label = s_('TanukiBot|Ask GitLab Duo') + - label = s_('TanukiBot|GitLab Duo Chat') = render Pajamas::ButtonComponent.new(variant: :confirm, category: :secondary, icon: 'tanuki-ai', diff --git a/app/views/layouts/nav/_top_bar.html.haml b/app/views/layouts/nav/_top_bar.html.haml index 73b253e18bd..ef783b688e0 100644 --- a/app/views/layouts/nav/_top_bar.html.haml +++ b/app/views/layouts/nav/_top_bar.html.haml @@ -8,8 +8,8 @@ %div{ class: top_bar_class, data: { testid: 'top-bar' } } .top-bar-container.gl-display-flex.gl-align-items-center.gl-gap-2{ :class => top_bar_container_class } - if show_super_sidebar? - = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'js-super-sidebar-toggle-expand super-sidebar-toggle gl-ml-n3', title: _('Expand sidebar'), aria: { controls: 'super-sidebar', expanded: 'false', label: _('Navigation sidebar') } }) + = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'js-super-sidebar-toggle-expand super-sidebar-toggle gl-ml-n3', aria: { controls: 'super-sidebar', expanded: 'false', label: _('Primary navigation sidebar') } }) - elsif defined?(@left_sidebar) - = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'toggle-mobile-nav gl-ml-n3', data: { testid: 'toggle_mobile_nav_button' }, aria: { label: _('Open sidebar') } }) + = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'toggle-mobile-nav gl-ml-n3', data: { testid: 'toggle-mobile-nav-button' }, aria: { label: _('Open sidebar') } }) = render "layouts/nav/breadcrumbs/breadcrumbs" = render "layouts/nav/ask_duo_button" diff --git a/app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml b/app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml index b5f067cf42f..040793d616f 100644 --- a/app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml +++ b/app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml @@ -2,7 +2,7 @@ - unless @skip_current_level_breadcrumb - push_to_schema_breadcrumb(@breadcrumb_title, breadcrumb_title_link) -%nav.breadcrumbs{ 'aria-label': _('Breadcrumbs'), data: { testid: 'breadcrumb-links', qa_selector: 'breadcrumb_links_content' } } +%nav.breadcrumbs{ 'aria-label': _('Breadcrumbs'), data: { testid: 'breadcrumb-links' } } %ul.list-unstyled.breadcrumbs-list.js-breadcrumbs-list - unless hide_top_links = header_title @@ -11,7 +11,7 @@ = breadcrumb_list_item link_to(extra[:text], extra[:link]) = render "layouts/nav/breadcrumbs/collapsed_inline_list", location: :after - unless @skip_current_level_breadcrumb - %li{ data: { testid: 'breadcrumb-current-link', qa_selector: 'breadcrumb_current_link' } } + %li{ data: { testid: 'breadcrumb-current-link' } } = link_to @breadcrumb_title, breadcrumb_title_link -# haml-lint:disable InlineJavaScript %script{ type: 'application/ld+json' } diff --git a/app/views/layouts/project_settings.html.haml b/app/views/layouts/project_settings.html.haml index 29e30c4434f..7e5dd0d37c9 100644 --- a/app/views/layouts/project_settings.html.haml +++ b/app/views/layouts/project_settings.html.haml @@ -1,6 +1,7 @@ - page_title _("Settings") - nav "project" - add_page_specific_style 'page_bundles/settings' +- add_page_specific_style 'page_bundles/projects' - enable_search_settings locals: { container_class: 'gl-my-5' } diff --git a/app/views/layouts/terms.html.haml b/app/views/layouts/terms.html.haml index 29a561ae1a9..32f00a4c0c6 100644 --- a/app/views/layouts/terms.html.haml +++ b/app/views/layouts/terms.html.haml @@ -20,14 +20,11 @@ = render Pajamas::CardComponent.new do |c| - c.with_header do = brand_header_logo({add_gitlab_black_text: true}) + - if current_user + .gl-display-flex.gl-gap-2.gl-align-items-center + .gl-text-right.gl-line-height-normal + .gl-font-weight-bold= current_user.name + .gl-text-gray-700 @#{current_user.username} + = render Pajamas::AvatarComponent.new(current_user, size: 32, avatar_options: { data: { qa_selector: 'user_avatar_content' } }) - c.with_body do - - if header_link?(:user_dropdown) - .navbar-collapse - %ul.nav.navbar-nav - %li.header-user.dropdown - = link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do - = render Pajamas::AvatarComponent.new(current_user, size: 24, class: 'gl-mr-3', avatar_options: { data: { qa_selector: 'user_avatar_content' } }) - = sprite_icon('chevron-down') - .dropdown-menu.dropdown-menu-right - = render 'layouts/header/current_user_dropdown' = yield diff --git a/app/views/notify/build_ios_app_guide_email.html.haml b/app/views/notify/build_ios_app_guide_email.html.haml deleted file mode 100644 index e9f23d3c0f9..00000000000 --- a/app/views/notify/build_ios_app_guide_email.html.haml +++ /dev/null @@ -1,13 +0,0 @@ -%tr - %td{ bgcolor: "#ffffff", height: "auto", style: "max-width: 600px; width: 100%; text-align: center; height: 200px; padding: 25px 15px; mso-line-height-rule: exactly; min-height: 40px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;", valign: "middle", width: "100%" } - = inline_image_link(@message.logo_path, { width: '150', style: 'width: 150px;' }) - %h1{ style: "font-size: 40px; line-height: 46x; color: #000000; padding: 20px 0 0 0; font-weight: normal;" } - = @message.title -%tr - %td{ style: "padding: 10px 20px 30px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 18px; line-height: 24px;" } - %p{ style: "margin: 0 0 20px 0;" } - = @message.body_line1.html_safe -%tr - %td{ align: "center", style: "padding: 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" } - .cta_link.cta_link_primary= @message.cta_link - .cta_link.cta_link_secondary= @message.cta2_link diff --git a/app/views/notify/build_ios_app_guide_email.text.erb b/app/views/notify/build_ios_app_guide_email.text.erb deleted file mode 100644 index 59757b7c1b0..00000000000 --- a/app/views/notify/build_ios_app_guide_email.text.erb +++ /dev/null @@ -1,13 +0,0 @@ -<%= @message.title %> - -<%= @message.body_line1 %> - -<%= @message.cta_link %> - -<%= @message.cta2_link %> - -<%= @message.footer_links %> - -<%= @message.address %> - -<%= @message.unsubscribe %> diff --git a/app/views/notify/resource_access_tokens_about_to_expire_email.html.haml b/app/views/notify/resource_access_tokens_about_to_expire_email.html.haml index e4e34f6c8ee..35c2260f24f 100644 --- a/app/views/notify/resource_access_tokens_about_to_expire_email.html.haml +++ b/app/views/notify/resource_access_tokens_about_to_expire_email.html.haml @@ -11,3 +11,5 @@ %p - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url } = html_escape(_('You can create a new one or check them in your %{link_start}access tokens%{link_end} settings.')) % { link_start: link_start, link_end: '</a>'.html_safe } +%p + = @reason_text diff --git a/app/views/notify/resource_access_tokens_about_to_expire_email.text.erb b/app/views/notify/resource_access_tokens_about_to_expire_email.text.erb index bea74f09129..f57c3e7b0d0 100644 --- a/app/views/notify/resource_access_tokens_about_to_expire_email.text.erb +++ b/app/views/notify/resource_access_tokens_about_to_expire_email.text.erb @@ -9,3 +9,5 @@ <% end %> <%= _('You can create a new one or check them in your access token settings: %{target_url}') % { target_url: @target_url } %> + +<%= @reason_text %> diff --git a/app/views/organizations/organizations/index.html.haml b/app/views/organizations/organizations/index.html.haml index 04a90b7589f..4e58cc7b9c2 100644 --- a/app/views/organizations/organizations/index.html.haml +++ b/app/views/organizations/organizations/index.html.haml @@ -1,2 +1,5 @@ +- add_page_specific_style 'page_bundles/organizations' - page_title s_('Organization|Organizations') - header_title _("Your work"), root_path + +#js-organizations-index{ data: organization_index_app_data } diff --git a/app/views/organizations/organizations/new.html.haml b/app/views/organizations/organizations/new.html.haml index 4d7f552c87b..1a6c5a79ff6 100644 --- a/app/views/organizations/organizations/new.html.haml +++ b/app/views/organizations/organizations/new.html.haml @@ -1,3 +1,6 @@ +- add_page_specific_style 'page_bundles/organizations' - page_title s_('Organization|New organization') - header_title _("Your work"), root_path - add_to_breadcrumbs s_('Organization|Organizations'), organizations_path + +#js-organizations-new{ data: { app_data: organization_new_app_data } } diff --git a/app/views/organizations/settings/general.html.haml b/app/views/organizations/settings/general.html.haml new file mode 100644 index 00000000000..94892ef9fbb --- /dev/null +++ b/app/views/organizations/settings/general.html.haml @@ -0,0 +1 @@ +- page_title _("General settings") diff --git a/app/views/profiles/chat_names/index.html.haml b/app/views/profiles/chat_names/index.html.haml index 7a63fc30d9c..99284abb73d 100644 --- a/app/views/profiles/chat_names/index.html.haml +++ b/app/views/profiles/chat_names/index.html.haml @@ -13,7 +13,7 @@ - if @chat_names.present? .table-responsive - %table.table + %table.table.gl-table %thead %tr %th= _('Team domain') diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml index 1307c388041..94671b69b5e 100644 --- a/app/views/profiles/keys/_key_details.html.haml +++ b/app/views/profiles/keys/_key_details.html.haml @@ -46,7 +46,7 @@ .gl-display-flex %pre.well-pre.gl-pl-5.gl-mb-0.gl-border-0 = @key.key - = deprecated_clipboard_button(title: s_('Profiles|Copy SSH key'), text: @key.key, class: 'gl-bg-gray-10 gl-px-3! gl-border-none! gl-rounded-top-left-none! gl-rounded-bottom-left-none!') + = clipboard_button(title: s_('Profiles|Copy SSH key'), text: @key.key, category: :tertiary, size: :medium) = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0'}) do |c| - c.with_header do diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 42297a0cf3d..ff0b31da022 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -18,24 +18,22 @@ - register_2fa_token = _('We recommend using cloud-based authenticator applications that can restore access if you lose your hardware device.') = register_2fa_token.html_safe = link_to _('What are some examples?'), help_page_path('user/profile/account/two_factor_authentication', anchor: 'enable-one-time-password'), target: '_blank', rel: 'noopener noreferrer' - .row.gl-mb-3 - .col-md-4.gl-min-w-fit-content - .gl-p-2.gl-mb-3{ style: 'background: #fff' } - = raw @qr_code - .col-md-8 - = render Pajamas::CardComponent.new do |c| - - c.with_body do - %p.gl-mt-0.gl-mb-3.gl-font-weight-bold - = _("Can't scan the code?") - %p.gl-mt-0.gl-mb-3 - = _('To add the entry manually, provide the following details to the application on your phone.') - %p.gl-mt-0.gl-mb-0 - = _('Account: %{account}') % { account: @account_string } - %p.gl-mt-0.gl-mb-0{ data: { qa_selector: 'otp_secret_content' } } - = _('Key:') - %code.two-factor-secret= current_user.otp_secret.scan(/.{4}/).join(' ') - %p.gl-mb-0.two-factor-new-manual-content - = _('Time based: Yes') + .gl-p-2.gl-mb-3{ style: 'background: #fff' } + = raw @qr_code + .gl-mb-5 + = render Pajamas::CardComponent.new do |c| + - c.with_body do + %p.gl-mt-0.gl-mb-3.gl-font-weight-bold + = _("Can't scan the code?") + %p.gl-mt-0.gl-mb-3 + = _("To add the entry manually, provide the following details to the application on your phone.") + %p.gl-mt-0.gl-mb-0 + = _('Account: %{account}') % { account: @account_string } + %p.gl-mt-0.gl-mb-0{ data: { qa_selector: 'otp_secret_content' } } + = _('Key:') + %code.two-factor-secret= current_user.otp_secret.scan(/.{4}/).join(' ') + %p.gl-mb-0.two-factor-new-manual-content + = _('Time based: Yes') = form_tag profile_two_factor_auth_path, method: :post do |f| - if @error = render Pajamas::AlertComponent.new(title: @error[:message], diff --git a/app/views/projects/_commit_button.html.haml b/app/views/projects/_commit_button.html.haml index 19943aa68a3..afb241dba7c 100644 --- a/app/views/projects/_commit_button.html.haml +++ b/app/views/projects/_commit_button.html.haml @@ -1,5 +1,5 @@ .gl-display-flex.gl-mt-7 - - submit_button_options = { type: :submit, variant: :confirm, button_options: { id: 'commit-changes', class: 'js-commit-button', data: { qa_selector: 'commit_button' } } } + - submit_button_options = { type: :submit, variant: :confirm, button_options: { id: 'commit-changes', class: 'js-commit-button', data: { testid: 'commit-button' } } } = render Pajamas::ButtonComponent.new(**submit_button_options) do = _('Commit changes') = render Pajamas::ButtonComponent.new(loading: true, disabled: true, **submit_button_options.merge({ button_options: { class: 'js-commit-button-loading gl-display-none' } })) do diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index 58c760c54e8..cc11997d809 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -2,7 +2,7 @@ - project = local_assigns.fetch(:project) -= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card', data: { qa_selector: 'export_project_content' } }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| += render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card', data: { testid: 'export-project-content' } }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| - c.with_header do .gl-new-card-title-wrapper %h4.gl-new-card-title= _('Export project') @@ -27,10 +27,10 @@ - if project.export_status == :finished = render Pajamas::ButtonComponent.new(href: download_export_project_path(project), method: :get, - button_options: { ref: 'nofollow', download: '', data: { qa_selector: 'download_export_link' } }) do + button_options: { ref: 'nofollow', download: '', data: { testid: 'download-export-link' } }) do = _('Download export') = render Pajamas::ButtonComponent.new(href: generate_new_export_project_path(project), method: :post) do = _('Generate new export') - else - = render Pajamas::ButtonComponent.new(href: export_project_path(project), method: :post, button_options: { data: { qa_selector: 'export_project_link' } }) do + = render Pajamas::ButtonComponent.new(href: export_project_path(project), method: :post, button_options: { data: { testid: 'export-project-link' } }) do = _('Export project') diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index cb341ede9de..7445a403865 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -21,7 +21,7 @@ #js-fork-info{ data: vue_fork_divergence_data(project, ref) } - if is_project_overview && has_project_shortcut_buttons - .project-buttons.gl-mb-5.js-show-on-project-root{ data: { qa_selector: 'project_buttons' } } + .project-buttons.gl-mb-5.js-show-on-project-root{ data: { testid: 'project-buttons' } } = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout), project_buttons: true #js-tree-list{ data: vue_file_list_data(project, ref) } diff --git a/app/views/projects/_find_file_link.html.haml b/app/views/projects/_find_file_link.html.haml index 4ad2c339bcc..83f1370b88e 100644 --- a/app/views/projects/_find_file_link.html.haml +++ b/app/views/projects/_find_file_link.html.haml @@ -1,2 +1,2 @@ -= link_button_to project_find_file_path(@project, @ref), class: 'shortcuts-find-file', rel: 'nofollow' do += link_button_to project_find_file_path(@project, @ref, ref_type: @ref_type), class: 'shortcuts-find-file', rel: 'nofollow' do = _('Find file') diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 759ec541af5..93f4fe62568 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -8,17 +8,17 @@ %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') %div - %h1.home-panel-title.gl-font-size-h1.gl-mt-3.gl-mb-2.gl-display-flex.gl-word-break-word{ data: { qa_selector: 'project_name_content' }, itemprop: 'name' } + %h1.home-panel-title.gl-font-size-h1.gl-mt-3.gl-mb-2.gl-display-flex.gl-word-break-word{ data: { testid: 'project-name-content' }, itemprop: 'name' } = @project.name = visibility_level_content(@project, css_class: 'visibility-icon gl-text-secondary gl-ml-2', icon_css_class: 'icon') = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project, additional_classes: 'gl-align-self-center gl-ml-2' - if @project.group = render_if_exists 'shared/tier_badge', source: @project, source_type: 'Project' - .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' } + .home-panel-metadata.gl-font-sm.gl-text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ data: { testid: 'project-id-content' }, itemprop: 'identifier' } - if can?(current_user, :read_project, @project) %span.gl-display-inline-block.gl-vertical-align-middle = s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id } - = deprecated_clipboard_button(title: s_('ProjectPage|Copy project ID'), text: @project.id) + = clipboard_button(title: s_('ProjectPage|Copy project ID'), text: @project.id) - if current_user %span.gl-ml-3.gl-mb-3 = render 'shared/members/access_request_links', source: @project @@ -52,13 +52,13 @@ = render_if_exists "projects/home_mirror" - if @project.badges.present? - .project-badges.mb-2{ data: { qa_selector: 'project_badges_content' } } + .project-badges.mb-2{ data: { testid: 'project-badges-content' } } - @project.badges.each do |badge| - badge_link_url = badge.rendered_link_url(@project) %a.gl-mr-3{ href: badge_link_url, target: '_blank', rel: 'noopener noreferrer', - data: { qa_selector: 'badge_image_link', qa_link_url: badge_link_url } }> + data: { testid: 'badge-image-link', qa_link_url: badge_link_url } }> %img.project-badge{ src: badge.rendered_image_url(@project), 'aria-hidden': true, alt: 'Project badge' }> diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml index 89c91887d19..240e9519975 100644 --- a/app/views/projects/_last_push.html.haml +++ b/app/views/projects/_last_push.html.haml @@ -16,5 +16,5 @@ - if create_mr_button_from_event?(event) - c.with_actions do - = render Pajamas::ButtonComponent.new(variant: :confirm, href: create_mr_path_from_push_event(event), button_options: { data: { qa_selector: 'create_merge_request_button' }}) do + = render Pajamas::ButtonComponent.new(variant: :confirm, href: create_mr_path_from_push_event(event), button_options: { data: { testid: 'create-merge-request-button' }}) do = _('Create merge request') diff --git a/app/views/projects/_transfer.html.haml b/app/views/projects/_transfer.html.haml index cf0634ee411..43bb3627c4f 100644 --- a/app/views/projects/_transfer.html.haml +++ b/app/views/projects/_transfer.html.haml @@ -3,7 +3,7 @@ - hidden_input_id = "new_namespace_id" - initial_data = { button_text: s_('ProjectSettings|Transfer project'), confirm_danger_message: transfer_project_message(@project), phrase: @project.name, target_form_id: form_id, target_hidden_input_id: hidden_input_id, project_id: @project.id } -= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card', data: { qa_selector: 'transfer_project_content' } }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| += render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card', data: { testid: 'transfer-project-content' } }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| - c.with_header do .gl-new-card-title-wrapper %h4.gl-new-card-title.warning-title= _('Transfer project') diff --git a/app/views/projects/artifacts/file.html.haml b/app/views/projects/artifacts/file.html.haml index 5b9e5ad584f..d24df0d3472 100644 --- a/app/views/projects/artifacts/file.html.haml +++ b/app/views/projects/artifacts/file.html.haml @@ -12,8 +12,6 @@ = render 'projects/blob/header_content', blob: blob .file-actions.d-none.d-sm-block - = render 'projects/blob/viewer_switcher', blob: blob - .btn-group{ role: "group" }< = copy_blob_source_button(blob) = download_blob_button(blob) diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index e2cad2fb3d7..bd0f4577a32 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -1,5 +1,6 @@ - page_title _("Blame"), @blob.path, @ref - add_page_specific_style 'page_bundles/tree' +- add_page_specific_style 'page_bundles/projects' - blame_streaming_url = blame_pages_streaming_url(@id, @project) - if @blame_mode.streaming? && @blame_pagination.total_extra_pages > 0 diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index 543bdaf46df..2d9b7ada015 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -2,6 +2,7 @@ - project = @project.present(current_user: current_user) - ref = local_assigns[:ref] || @ref - expanded = params[:expanded].present? +- add_page_specific_style 'page_bundles/projects' -# If the blob has a RichViewer we preload the content except for GeoJSON since it is handled by Vue - if blob.rich_viewer && blob.extension != 'geojson' - add_page_startup_api_call local_assigns.fetch(:viewer_url) { url_for(safe_params.merge(viewer: blob.rich_viewer.type, format: :json)) } diff --git a/app/views/projects/blob/_breadcrumb.html.haml b/app/views/projects/blob/_breadcrumb.html.haml index 539453bf6af..c140eecd8c1 100644 --- a/app/views/projects/blob/_breadcrumb.html.haml +++ b/app/views/projects/blob/_breadcrumb.html.haml @@ -22,11 +22,11 @@ -# only show normal/blame view links for text files - if blob.readable_text? - if blame - = link_button_to _('Normal view'), project_blob_path(@project, @id) + = link_button_to _('Normal view'), project_blob_path(@project, @id, ref_type: @ref_type) - else - = link_button_to _('Blame'), project_blame_path(@project, @id), class: 'js-blob-blame-link' unless blob.empty? + = link_button_to _('Blame'), project_blame_path(@project, @id, ref_type: @ref_type), class: 'js-blob-blame-link' unless blob.empty? - = link_button_to _('History'), project_commits_path(@project, @id) + = link_button_to _('History'), project_commits_path(@project, @id, ref_type: @ref_type) = link_button_to _('Permalink'), project_blob_path(@project, tree_join(@commit.sha, @path)), class: 'js-data-file-blob-permalink-url' diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index 9c07713b9f8..a1d3bef2914 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -4,7 +4,6 @@ = render 'projects/blob/header_content', blob: blob .file-actions.gl-display-flex.gl-align-items-center.gl-flex-wrap.gl-md-justify-content-end< - = render 'projects/blob/viewer_switcher', blob: blob unless blame = render 'shared/web_ide_button', blob: blob .btn-group.gl-ml-3{ role: "group" } = copy_blob_source_button(blob) unless blame diff --git a/app/views/projects/blob/_viewer_switcher.html.haml b/app/views/projects/blob/_viewer_switcher.html.haml deleted file mode 100644 index 043b8629289..00000000000 --- a/app/views/projects/blob/_viewer_switcher.html.haml +++ /dev/null @@ -1,12 +0,0 @@ -- if blob.show_viewer_switcher? - - simple_viewer = blob.simple_viewer - - rich_viewer = blob.rich_viewer - - .btn-group.js-blob-viewer-switcher.gl-ml-3{ role: "group" }> - - simple_label = "Display #{simple_viewer.switcher_title}" - %button.btn.gl-button.btn-default.btn-icon.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => simple_label, title: simple_label, data: { viewer: 'simple', container: 'body' } }> - = sprite_icon(simple_viewer.switcher_icon) - - - rich_label = "Display #{rich_viewer.switcher_title}" - %button.btn.gl-button.btn-default.btn-icon.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => rich_label, title: rich_label, data: { viewer: 'rich', container: 'body' } }> - = sprite_icon(rich_viewer.switcher_icon) diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml index 195dc03632a..74f1688a2db 100644 --- a/app/views/projects/blob/edit.html.haml +++ b/app/views/projects/blob/edit.html.haml @@ -12,7 +12,7 @@ - link_end = '</a>'.html_safe - external_link_icon = content_tag 'span', { aria: { label: _('Opens new window') }} do - sprite_icon('external-link', css_class: 'gl-icon').html_safe - - if @different_project + - if commit_to_fork = _("Error: Can't edit this file. The fork and upstream project have diverged. %{link_start}Edit the file on the fork %{icon}%{link_end}, and create a merge request.").html_safe % {link_start: blob_link_start % { url: project_blob_path(@project_to_commit_into, @id) } , link_end: link_end, icon: external_link_icon } - else - blob_url = project_blob_path(@project, @id) diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index 9ec824f64d4..82f517e8a84 100644 --- a/app/views/projects/blob/show.html.haml +++ b/app/views/projects/blob/show.html.haml @@ -4,7 +4,7 @@ - signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @last_commit, limit: 1) - content_for :prefetch_asset_tags do - webpack_preload_asset_tag('monaco', prefetch: true) -- add_page_startup_graphql_call('repository/blob_info', { projectPath: @project.full_path, ref: current_ref, refType: @ref_type.to_s, filePath: @blob.path, shouldFetchRawText: @blob.rendered_as_text? && !@blob.rich_viewer }) +- add_page_startup_graphql_call('repository/blob_info', { projectPath: @project.full_path, ref: current_ref, refType: @ref_type.to_s.upcase, filePath: @blob.path, shouldFetchRawText: @blob.rendered_as_text? && !@blob.rich_viewer }) .js-signature-container{ data: { 'signatures-path': signatures_path } } diff --git a/app/views/projects/blob/viewers/_loading.html.haml b/app/views/projects/blob/viewers/_loading.html.haml index d8efaf9ad95..40b64cac1f7 100644 --- a/app/views/projects/blob/viewers/_loading.html.haml +++ b/app/views/projects/blob/viewers/_loading.html.haml @@ -1 +1 @@ -= gl_loading_icon(size: "md", css_class: "gl-my-4", data: { qa_selector: 'spinner_placeholder' }) += gl_loading_icon(size: "md", css_class: "gl-my-4", data: { testid: 'spinner-placeholder' }) diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index 7c52350f101..61961172eb2 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -28,7 +28,7 @@ .pipeline-status.d-none.d-md-block< - if commit_status - = render 'ci/status/icon', size: 16, status: commit_status, option_css_classes: 'gl-display-inline-flex gl-vertical-align-middle gl-mr-3' + = render 'ci/status/icon', size: 16, status: commit_status - elsif show_commit_status .gl-display-inline-flex.gl-vertical-align-middle.gl-mr-3 %svg.s16 diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml index b5679bc512c..0e645eda678 100644 --- a/app/views/projects/buttons/_clone.html.haml +++ b/app/views/projects/buttons/_clone.html.haml @@ -15,7 +15,7 @@ .input-group.btn-group = text_field_tag :ssh_project_clone, ssh_clone_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'ssh_clone_url_content' } .input-group-append - = deprecated_clipboard_button(target: '#ssh_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default") + = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL"), category: :primary, size: :medium) = render_if_exists 'projects/buttons/geo' - if http_enabled? %li.pt-2{ class: 'gl-px-4!' } @@ -24,7 +24,7 @@ .input-group.btn-group = text_field_tag :http_project_clone, http_clone_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'http_clone_url_content' } .input-group-append - = deprecated_clipboard_button(target: '#http_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default") + = clipboard_button(target: '#http_project_clone', title: _("Copy URL"), category: :primary, size: :medium) = render_if_exists 'projects/buttons/geo' = render_if_exists 'projects/buttons/kerberos_clone_field' %li.divider.mt-2 diff --git a/app/views/projects/buttons/_compare.html.haml b/app/views/projects/buttons/_compare.html.haml new file mode 100644 index 00000000000..82b1b837fbb --- /dev/null +++ b/app/views/projects/buttons/_compare.html.haml @@ -0,0 +1,8 @@ +- project = local_assigns.fetch(:project) +- ref = local_assigns.fetch(:ref, nil) +- root_ref = local_assigns.fetch(:root_ref, nil) +- unless ref.blank? || root_ref == ref + - compare_path = project_compare_index_path(project, from: root_ref, to: ref) + + = link_button_to compare_path, class: 'shortcuts-compare', rel: 'nofollow' do + = _('Compare') diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index 23d18236738..b3282742407 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -6,7 +6,7 @@ - if !project.empty_repo? && can?(current_user, :download_code, project) - archive_prefix = "#{project.path}-#{ref.tr('/', '-')}" .project-action-button.dropdown.gl-dropdown.inline{ class: css_class }> - = render Pajamas::ButtonComponent.new(button_options: { class: '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' } }) do + = render Pajamas::ButtonComponent.new(button_options: { class: '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: { testid: 'download-source-code-button' } }) do = sprite_icon('download', css_class: 'gl-icon dropdown-icon') %span.sr-only= _('Select Archive Format') = sprite_icon('chevron-down', css_class: 'gl-icon dropdown-chevron') @@ -15,7 +15,7 @@ %h5.m-0.dropdown-bold-header= _('Download source code') .dropdown-menu-content = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: nil - #js-directory-downloads{ data: { links: directory_download_links(project, ref, archive_prefix).to_json } } + .js-directory-downloads{ data: { links: directory_download_links(project, ref, archive_prefix).to_json } } - if pipeline && pipeline.latest_builds_with_artifacts.any? %section.border-top.pt-1.mt-1 %h5.m-0.dropdown-bold-header= _('Download artifacts') diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index 1034f06f722..be2bf43cbb9 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' - add_page_specific_style 'page_bundles/merge_request' +- add_page_specific_style 'page_bundles/projects' - page_title _("Commits"), @ref = content_for :meta_tags do diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 0158018ecc0..4e84a6ef7e7 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -18,7 +18,7 @@ %p.gl-text-secondary= _('Update your project name, topics, description, and avatar.') .settings-content= render 'projects/settings/general' -%section.settings.sharing-permissions.no-animate#js-shared-permissions{ class: ('expanded' if expanded), data: { qa_selector: 'visibility_features_permissions_content' } } +%section.settings.sharing-permissions.no-animate#js-shared-permissions{ class: ('expanded' if expanded), data: { testid: 'visibility-features-permissions-content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Visibility, project features, permissions') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do @@ -38,7 +38,7 @@ - c.with_body do = _('On the left sidebar, select %{merge_requests_link} to view them.').html_safe % { merge_requests_link: link_to('Settings > Merge requests', project_settings_merge_requests_path(@project)).html_safe } -%section.settings.no-animate{ class: ('expanded' if expanded), data: { qa_selector: 'badges_settings_content' } } +%section.settings.no-animate{ class: ('expanded' if expanded), data: { testid: 'badges-settings-content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = s_('ProjectSettings|Badges') @@ -56,7 +56,7 @@ = render 'projects/service_desk_settings' -%section.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded), data: { qa_selector: 'advanced_settings_content' } } +%section.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded), data: { testid: 'advanced-settings-content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Advanced') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do @@ -107,8 +107,8 @@ .input-group-prepend .input-group-text #{Gitlab::Utils.append_path(root_url, @project.namespace.full_path)}/ - = f.text_field :path, class: 'form-control gl-form-input-xl', data: { qa_selector: 'project_path_field' } - = f.submit _('Change path'), class: "btn-danger", data: { qa_selector: 'change_path_button' }, pajamas_button: true + = f.text_field :path, class: 'form-control gl-form-input-xl', data: { testid: 'project-path-field' } + = f.submit _('Change path'), class: "btn-danger", data: { testid: 'change-path-button' }, pajamas_button: true = render 'transfer', project: @project diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index deb3c33f733..902a5df9394 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -17,7 +17,7 @@ %p = _('You can get started by cloning the repository or start adding files to it with one of the following options.') -.project-buttons{ data: { qa_selector: 'quick_actions_container' } } +.project-buttons{ data: { testid: 'quick-actions-container' } } .project-clone-holder.d-block.d-md-none.gl-mt-3.gl-mr-3 = render "shared/mobile_clone_panel" diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml index 541b8c1147d..0c760ab82c9 100644 --- a/app/views/projects/find_file/show.html.haml +++ b/app/views/projects/find_file/show.html.haml @@ -1,13 +1,16 @@ - page_title _("Find File"), @ref - add_page_specific_style 'page_bundles/tree' +- add_page_specific_style 'page_bundles/projects' -.file-finder-holder.tree-holder.clearfix.js-file-finder.gl-pt-4{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, format: :json))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @ref)) } +- tree_path = project_tree_path(@project, @ref) +- blob_path = project_blob_path(@project, @ref) +.file-finder-holder.tree-holder.clearfix.js-file-finder.gl-pt-4{ data: { file_find_url: "#{escape_javascript(project_files_path(@project, @ref, ref_type: @ref_type, format: :json))}", find_tree_url: escape_javascript(tree_path), blob_url_template: escape_javascript(blob_path), ref_type: @ref_type } } .nav-block.gl-xs-mr-0 .tree-ref-holder.gl-xs-mb-3.gl-xs-w-full.gl-max-w-26 - #js-blob-ref-switcher{ data: { project_id: @project.id, ref: @ref, namespace: "/-/find_file" } } + #js-blob-ref-switcher{ data: { project_id: @project.id, ref: @ref, ref_type: @ref_type, namespace: "/-/find_file" } } %ul.breadcrumb.repo-breadcrumb.gl-flex-nowrap %li.breadcrumb-item.gl-white-space-nowrap - = link_to project_tree_path(@project, @ref) do + = link_to project_tree_path(@project, @ref, ref_type: @ref_type) do = @project.path %li.file-finder.breadcrumb-item %input#file_find.form-control.file-finder-input{ type: "text", placeholder: _('Find by path'), autocomplete: 'off' } diff --git a/app/views/projects/issues/_details_content.html.haml b/app/views/projects/issues/_details_content.html.haml index 51ffb68f4e5..881e6863040 100644 --- a/app/views/projects/issues/_details_content.html.haml +++ b/app/views/projects/issues/_details_content.html.haml @@ -16,7 +16,7 @@ = edited_time_ago_with_tooltip(issuable, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago') .js-issue-widgets - = render 'projects/issues/emoji_block', issuable: issuable, api_awards_path: api_awards_path + = render 'projects/issues/emoji_block', issuable: issuable, api_awards_path: api_awards_path, new_custom_emoji_path: new_custom_emoji_path(@project.group) .js-issue-widgets = render 'projects/issues/sentry_stack_trace', issuable: issuable diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index c6e5102889a..5cb7fa8816e 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -13,4 +13,5 @@ current_user_data: UserSerializer.new.represent(current_user, {only_path: true}, CurrentUserEntity).to_json, can_add_timeline_events: "#{can?(current_user, :admin_incident_management_timeline_event, @issue)}", report_abuse_path: add_category_abuse_reports_path, - new_comment_template_path: profile_comment_templates_path } } + new_comment_template_path: profile_comment_templates_path, + new_custom_emoji_path: new_custom_emoji_path(@project.group) } } diff --git a/app/views/projects/issues/_emoji_block.html.haml b/app/views/projects/issues/_emoji_block.html.haml index 7eb3c0f5c9f..f9eee9ec99e 100644 --- a/app/views/projects/issues/_emoji_block.html.haml +++ b/app/views/projects/issues/_emoji_block.html.haml @@ -1,8 +1,9 @@ - api_awards_path = local_assigns.fetch(:api_awards_path, nil) +- new_custom_emoji_path = local_assigns.fetch(:new_custom_emoji_path, nil) .emoji-block.emoji-block-sticky .row.gl-m-0.gl-justify-content-space-between .js-noteable-awards - = render 'award_emoji/awards_block', awardable: issuable, inline: true, api_awards_path: api_awards_path + = render 'award_emoji/awards_block', awardable: issuable, inline: true, api_awards_path: api_awards_path, new_custom_emoji_path: new_custom_emoji_path .new-branch-col.gl-font-size-0.gl-my-2 = render 'new_branch' if show_new_branch_button? diff --git a/app/views/projects/issues/new.html.haml b/app/views/projects/issues/new.html.haml index 64143502b77..57f78152159 100644 --- a/app/views/projects/issues/new.html.haml +++ b/app/views/projects/issues/new.html.haml @@ -1,4 +1,5 @@ - add_page_specific_style 'page_bundles/merge_request' +- add_page_specific_style 'page_bundles/labels' - add_to_breadcrumbs _("Issues"), project_issues_path(@project) - breadcrumb_title _("New") - page_title _("New Issue") diff --git a/app/views/projects/jobs/index.html.haml b/app/views/projects/jobs/index.html.haml index 0073c6b89cd..8f6efbf9c83 100644 --- a/app/views/projects/jobs/index.html.haml +++ b/app/views/projects/jobs/index.html.haml @@ -3,4 +3,4 @@ - add_page_specific_style 'page_bundles/merge_request' - admin = local_assigns.fetch(:admin, false) -#js-jobs-table{ data: { admin: admin, full_path: @project.full_path, job_statuses: job_statuses.to_json, pipeline_editor_path: project_ci_pipeline_editor_path(@project), empty_state_svg_path: image_path('jobs-empty-state.svg') } } +#js-jobs-table{ data: { admin: admin, full_path: @project.full_path, job_statuses: job_statuses.to_json, pipeline_editor_path: project_ci_pipeline_editor_path(@project), empty_state_svg_path: image_path('illustrations/empty-state/empty-pipeline-md.svg') } } diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 4b27b344498..03086b19984 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -3,6 +3,7 @@ - search = params[:search] - subscribed = params[:subscribed] - labels_or_filters = @labels.exists? || @prioritized_labels.exists? || search.present? || subscribed.present? +- add_page_specific_style 'page_bundles/labels' - if labels_or_filters #js-promote-label-modal diff --git a/app/views/projects/merge_requests/_awards_block.html.haml b/app/views/projects/merge_requests/_awards_block.html.haml index c1952793e72..f657f683a6d 100644 --- a/app/views/projects/merge_requests/_awards_block.html.haml +++ b/app/views/projects/merge_requests/_awards_block.html.haml @@ -1,2 +1,2 @@ .emoji-block.emoji-list-container.js-noteable-awards - = render 'award_emoji/awards_block', awardable: @merge_request, inline: true, api_awards_path: award_emoji_merge_request_api_path(@merge_request) + = render 'award_emoji/awards_block', awardable: @merge_request, inline: true, api_awards_path: award_emoji_merge_request_api_path(@merge_request), new_custom_emoji_path: new_custom_emoji_path(@project.group) diff --git a/app/views/projects/merge_requests/_description.html.haml b/app/views/projects/merge_requests/_description.html.haml index 5590f9e6184..89eed0789e8 100644 --- a/app/views/projects/merge_requests/_description.html.haml +++ b/app/views/projects/merge_requests/_description.html.haml @@ -1,6 +1,6 @@ %div - if @merge_request.description.present? - .description{ class: ['gl-mt-4!', can?(current_user, :update_merge_request, @merge_request) ? 'js-task-list-container' : ''], data: { qa_selector: 'description_content' } } + .description{ class: ['gl-mt-4!', can?(current_user, :update_merge_request, @merge_request) ? 'js-task-list-container' : ''], data: { testid: 'description-content' } } .md = markdown_field(@merge_request, :description) %textarea.hidden.js-task-list-field{ data: { value: @merge_request.description } } diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index 4a7aa9a86ab..21a74d30ba5 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -43,7 +43,7 @@ = link_to_label(label, type: :merge_request, small: true) .issuable-meta - %ul.controls.d-flex.align-items-end + %ul.controls.d-flex.align-items-center - if merge_request.merged? - merged_at = merge_request.merged_at ? l(merge_request.merged_at.to_time) : _("Merge date & time could not be determined") %li.d-none.d-sm-flex diff --git a/app/views/projects/merge_requests/_mr_box.html.haml b/app/views/projects/merge_requests/_mr_box.html.haml index 1774401ed78..08b3eb4e5b6 100644 --- a/app/views/projects/merge_requests/_mr_box.html.haml +++ b/app/views/projects/merge_requests/_mr_box.html.haml @@ -1,3 +1,3 @@ .detail-page-description.gl-pt-2.gl-pb-4.gl-display-flex.gl-align-items-baseline.gl-flex-wrap{ class: "#{'is-merge-request' if moved_mr_sidebar_enabled? && !fluid_layout}" } - = render 'shared/issuable/status_box', issuable: @merge_request + .js-mr-header{ data: { project_path: @merge_request.project.path_with_namespace, hidden: @merge_request.hidden?.to_s, iid: @merge_request.iid, state: @merge_request.state } } = merge_request_header(@project, @merge_request) diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index f0e7df8a379..1b0aba8d496 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -2,7 +2,7 @@ - can_reopen_merge_request = can?(current_user, :reopen_merge_request, @merge_request) - are_close_and_open_buttons_hidden = merge_request_button_hidden?(@merge_request, true) && merge_request_button_hidden?(@merge_request, false) - hide_gutter_toggle = local_assigns.fetch(:hide_gutter_toggle, false) -- cache_key = [@project, @merge_request, can_update_merge_request, can_reopen_merge_request, are_close_and_open_buttons_hidden, current_user&.preferred_language, "1.1-updated_header", moved_mr_sidebar_enabled?, hide_gutter_toggle, fluid_layout] +- cache_key = [@project, @merge_request, can_update_merge_request, can_reopen_merge_request, are_close_and_open_buttons_hidden, current_user&.preferred_language, "1.1-updated_header", moved_mr_sidebar_enabled?, hide_gutter_toggle, fluid_layout, Gitlab::CurrentSettings.gitpod_enabled, current_user&.gitpod_enabled] = cache(cache_key, expires_in: 1.day) do - if @merge_request.closed_or_merged_without_fork? @@ -14,10 +14,8 @@ .detail-page-header.border-bottom-0.gl-display-block.gl-pt-5{ class: "gl-md-display-flex! #{'is-merge-request' if moved_mr_sidebar_enabled? && !fluid_layout}" } .detail-page-header-body - .issuable-meta.gl-display-flex - .js-header-metadata-root{ data: { hidden: @merge_request.hidden?.to_s } } - %h1.title.page-title.gl-font-size-h-display.gl-my-0.gl-display-inline-block{ data: { qa_selector: 'title_content' } } - = markdown_field(@merge_request, :title) + %h1.title.page-title.gl-font-size-h-display.gl-my-0.gl-display-inline-block.gl-flex-grow-1{ data: { testid: 'title-content' } } + = markdown_field(@merge_request, :title) - unless hide_gutter_toggle %div @@ -26,7 +24,7 @@ .detail-page-header-actions.gl-align-self-start.is-merge-request.js-issuable-actions.gl-display-flex - if can_update_merge_request - = render Pajamas::ButtonComponent.new(href: edit_project_merge_request_path(@project, @merge_request), button_options: {class: "gl-display-none gl-md-display-block js-issuable-edit", data: { qa_selector: "edit_title_button" }}) do + = render Pajamas::ButtonComponent.new(href: edit_project_merge_request_path(@project, @merge_request), button_options: {class: "gl-display-none gl-md-display-block js-issuable-edit", data: { testid: "edit-title-button" }}) do = _('Edit') - if @merge_request.source_project diff --git a/app/views/projects/merge_requests/_page.html.haml b/app/views/projects/merge_requests/_page.html.haml index dfb18b52021..637980bd2f8 100644 --- a/app/views/projects/merge_requests/_page.html.haml +++ b/app/views/projects/merge_requests/_page.html.haml @@ -50,7 +50,11 @@ #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-display-flex.gl-gap-3 + .js-sidebar-todo-widget-root{ data: { project_path: @issuable_sidebar[:project_full_path], iid: @issuable_sidebar[:iid], id: @issuable_sidebar[:id] } } + - if notifications_todos_buttons_enabled? + .js-sidebar-subscriptions-widget-root{ data: { full_path: @issuable_sidebar[:project_full_path], iid: @issuable_sidebar[:iid] } } + .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 @@ -83,7 +87,8 @@ current_user_data: @current_user_data, is_locked: @merge_request.discussion_locked.to_s, report_abuse_path: add_category_abuse_reports_path, - new_comment_template_path: profile_comment_templates_path } } + new_comment_template_path: profile_comment_templates_path, + new_custom_emoji_path: new_custom_emoji_path(@project.group) } } - 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 diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index abf2949938c..954bd48fb41 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -8,7 +8,7 @@ = f.hidden_field(:redirect_path, name: :redirect_path, id: :redirect_path, value: @redirect_path) .form-group = f.label :title, _('Title') - = f.text_field :title, maxlength: 255, class: 'form-control gl-form-input', data: { qa_selector: 'milestone_title_field' }, required: true, autofocus: true + = f.text_field :title, maxlength: 255, class: 'form-control gl-form-input', data: { testid: 'milestone-title-field' }, required: true, autofocus: true = render 'shared/milestones/form_dates', f: f .form-group = f.label :description, _('Description') @@ -28,7 +28,7 @@ = f.hidden_field :lock_version - if @milestone.new_record? - = f.submit _('Create milestone'), data: { qa_selector: 'create_milestone_button' }, class: 'gl-mr-2', pajamas_button: true + = f.submit _('Create milestone'), data: { testid: 'create-milestone-button' }, class: 'gl-mr-2', pajamas_button: true = link_button_to _('Cancel'), project_milestones_path(@project) - else = f.submit _('Save changes'), class: 'gl-mr-2', pajamas_button: true diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml index a7a21ef0440..a6c49d8000d 100644 --- a/app/views/projects/milestones/index.html.haml +++ b/app/views/projects/milestones/index.html.haml @@ -9,14 +9,14 @@ = render 'shared/milestones/search_form' = render 'shared/milestones_sort_dropdown' - if can?(current_user, :admin_milestone, @project) - = link_button_to new_project_milestone_path(@project), class: 'gl-ml-3', data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone'), variant: :confirm do + = link_button_to new_project_milestone_path(@project), class: 'gl-ml-3', data: { testid: "new-project-milestone-link" }, title: _('New milestone'), variant: :confirm do = _('New milestone') - if @milestones.blank? = render 'shared/empty_states/milestones_tab' do - if can?(current_user, :admin_milestone, @project) .text-center - = link_button_to new_project_milestone_path(@project), data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone'), variant: :confirm do + = link_button_to new_project_milestone_path(@project), data: { testid: "new-project-milestone-link" }, title: _('New milestone'), variant: :confirm do = _('New milestone') - else @@ -32,5 +32,5 @@ = render 'shared/empty_states/milestones' do - if can?(current_user, :admin_milestone, @project) .text-center - = link_button_to new_project_milestone_path(@project), data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone'), variant: :confirm do + = link_button_to new_project_milestone_path(@project), data: { testid: "new-project-milestone-link" }, title: _('New milestone'), variant: :confirm do = _('New milestone') diff --git a/app/views/projects/ml/models/index.html.haml b/app/views/projects/ml/models/index.html.haml index a1c6376e9b4..08f0db257ae 100644 --- a/app/views/projects/ml/models/index.html.haml +++ b/app/views/projects/ml/models/index.html.haml @@ -1,4 +1,4 @@ - breadcrumb_title s_('ModelRegistry|Model registry') - page_title s_('ModelRegistry|Model registry') -= render(Projects::Ml::ModelsIndexComponent.new(models: @models)) += render(Projects::Ml::ModelsIndexComponent.new(paginator: @paginator)) diff --git a/app/views/projects/ml/models/show.html.haml b/app/views/projects/ml/models/show.html.haml new file mode 100644 index 00000000000..be611e55304 --- /dev/null +++ b/app/views/projects/ml/models/show.html.haml @@ -0,0 +1,5 @@ +- add_to_breadcrumbs s_('ModelRegistry|Model registry'), project_ml_models_path(@model.project) +- breadcrumb_title @model.name +- page_title @model.name + += render(Projects::Ml::ShowMlModelComponent.new(model: @model)) diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index bf288d3601b..40acd123aaa 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -3,6 +3,7 @@ - page_title _('New Project') - header_title _("Projects"), dashboard_projects_path - add_page_specific_style 'page_bundles/new_namespace' +- add_page_specific_style 'page_bundles/projects' .project-edit-container .project-edit-errors diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml index 6b875ff904c..68b7bcd5bb5 100644 --- a/app/views/projects/notes/_actions.html.haml +++ b/app/views/projects/notes/_actions.html.haml @@ -19,6 +19,6 @@ .note-actions-item.gl-ml-0 = render Pajamas::ButtonComponent.new(category: :tertiary, icon: 'pencil', - button_options: { class: 'note-action-button js-note-edit has-tooltip', data: { container: 'body', qa_selector: 'edit_comment_button' }, title: _('Edit comment'), 'aria-label': _('Edit comment') }) + button_options: { class: 'note-action-button js-note-edit has-tooltip', data: { container: 'body', testid: 'edit-comment-button' }, title: _('Edit comment'), 'aria-label': _('Edit comment') }) = render 'projects/notes/more_actions_dropdown', note: note, note_editable: note_editable diff --git a/app/views/projects/notes/_more_actions_dropdown.html.haml b/app/views/projects/notes/_more_actions_dropdown.html.haml index 54d1bf012f3..b64824bf509 100644 --- a/app/views/projects/notes/_more_actions_dropdown.html.haml +++ b/app/views/projects/notes/_more_actions_dropdown.html.haml @@ -2,7 +2,7 @@ - if note_editable || !is_current_user %div{ class: "dropdown more-actions note-actions-item gl-ml-0!" } - = render Pajamas::ButtonComponent.new(icon: 'ellipsis_v', category: :tertiary, button_options: { class: 'note-action-button more-actions-toggle has-tooltip', data: { title: 'More actions', toggle: 'dropdown', container: 'body', qa_selector: 'more_actions_dropdown' }}) + = render Pajamas::ButtonComponent.new(icon: 'ellipsis_v', category: :tertiary, button_options: { class: 'note-action-button more-actions-toggle has-tooltip', data: { title: 'More actions', toggle: 'dropdown', container: 'body', testid: 'more-actions-dropdown' }}) %ul.dropdown-menu.more-actions-dropdown.dropdown-open-left %li = deprecated_clipboard_button(text: noteable_note_url(note), title: _('Copy reference'), button_text: _('Copy link'), class: 'btn-clipboard', hide_tooltip: true, hide_button_icon: true) @@ -11,6 +11,6 @@ .js-report-abuse-dropdown-item{ data: { report_abuse_path: add_category_abuse_reports_path, reported_user_id: note.author.id, reported_from_url: noteable_note_url(note) } } - 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 + = link_to note_url(note), method: :delete, data: { confirm: _('Are you sure you want to delete this comment?'), confirm_btn_variant: 'danger', testid: 'delete-comment-button' }, aria: { label: _('Delete comment') }, remote: true, class: 'js-note-delete' do %span.text-danger = _('Delete comment') diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml index 210f9c35c79..d47de725603 100644 --- a/app/views/projects/pipelines/new.html.haml +++ b/app/views/projects/pipelines/new.html.haml @@ -4,7 +4,6 @@ %h1.page-title.gl-font-size-h-display = s_('Pipeline|Run pipeline') -%hr #js-new-pipeline{ data: { project_id: @project.id, pipelines_path: project_pipelines_path(@project), diff --git a/app/views/projects/settings/_archive.html.haml b/app/views/projects/settings/_archive.html.haml index e7da3177cde..dcb37541a04 100644 --- a/app/views/projects/settings/_archive.html.haml +++ b/app/views/projects/settings/_archive.html.haml @@ -13,10 +13,10 @@ - if @project.archived? - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'unarchive-a-project') } %p= _("Unarchiving the project restores its members' ability to make commits, and create issues, comments, and other entities. %{strong_start}After you unarchive the project, it displays in the search and on the dashboard.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe } - = render Pajamas::ButtonComponent.new(method: :post, href: unarchive_project_path(@project), variant: :confirm, button_options: { aria: { label: _('Unarchive project') }, data: { confirm: _("Are you sure that you want to unarchive this project?"), qa_selector: 'unarchive_project_link' } }) do + = render Pajamas::ButtonComponent.new(method: :post, href: unarchive_project_path(@project), variant: :confirm, button_options: { aria: { label: _('Unarchive project') }, data: { confirm: _("Are you sure that you want to unarchive this project?"), testid: 'unarchive-project-link' } }) do = _('Unarchive project') - else - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'archive-a-project') } %p= _("Archiving the project makes it entirely read-only. It is hidden from the dashboard and doesn't display in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe } - = render Pajamas::ButtonComponent.new(method: :post, href: archive_project_path(@project), variant: :confirm, button_options: { aria: { label: _('Archive project') }, data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link', 'confirm-btn-variant': 'confirm' } }) do + = render Pajamas::ButtonComponent.new(method: :post, href: archive_project_path(@project), variant: :confirm, button_options: { aria: { label: _('Archive project') }, data: { confirm: _("Are you sure that you want to archive this project?"), testid: 'archive-project-link', 'confirm-btn-variant': 'confirm' } }) do = _('Archive project') diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml index f5c275827fc..47ea2f1a544 100644 --- a/app/views/projects/settings/_general.html.haml +++ b/app/views/projects/settings/_general.html.haml @@ -7,7 +7,7 @@ .form-group.col-md-5 = f.label :name, class: 'label-bold', for: 'project_name_edit' do = _('Project name') - = f.text_field :name, class: 'form-control gl-form-input', id: "project_name_edit", data: { qa_selector: 'project_name_field' } + = f.text_field :name, class: 'form-control gl-form-input', id: "project_name_edit", data: { testid: 'project-name-field' } .form-group.col-md-7 = f.label :id, class: 'label-bold' do @@ -37,4 +37,4 @@ %hr = link_button_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, variant: :danger, category: :secondary - = f.submit _('Save changes'), pajamas_button: true, class: "gl-mt-6", data: { qa_selector: 'save_naming_topics_avatar_button' } + = f.submit _('Save changes'), pajamas_button: true, class: "gl-mt-6", data: { testid: 'save-naming-topics-avatar-button' } 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 46cfcf20535..fd27b125602 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -20,7 +20,7 @@ %fieldset.builds-feature.js-auto-devops-settings .form-group = f.fields_for :auto_devops_attributes, @auto_devops do |form| - = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-3' }, footer_options: { class: auto_devops_enabled || 'hidden' }) do |c| + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-3' }, footer_options: { class: "js-extra-settings #{auto_devops_enabled || 'hidden'}", data: { testid: 'extra-auto-devops-settings' } }) do |c| - c.with_body do - autodevops_help_link = link_to _('Learn more.'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer' - auto_devops_badge = auto_devops_enabled ? (gl_badge_tag badge_for_auto_devops_scope(@project), { variant: :info }, { class: 'js-instance-default-badge gl-ml-3 gl-mt-n1'}) : '' diff --git a/app/views/projects/settings/integrations/_form.html.haml b/app/views/projects/settings/integrations/_form.html.haml index d2df01c22bb..6f37eb1b32d 100644 --- a/app/views/projects/settings/integrations/_form.html.haml +++ b/app/views/projects/settings/integrations/_form.html.haml @@ -14,13 +14,14 @@ - if integration.to_param === 'slack' = render 'shared/integrations/slack_notifications_deprecation_alert' -%h2.gl-mb-0.gl-display-flex.gl-align-items-center.gl-gap-3 +.gl-display-flex.gl-align-items-center.gl-gap-3 = render Pajamas::AvatarComponent.new(integration, size: 64, alt: '') - = integration.title - - if integration.operating? - = render Pajamas::BadgeComponent.new(_('Active'), variant: 'success', icon: 'status-success') - - elsif integration.persisted? - = render Pajamas::BadgeComponent.new(_('Inactive'), variant: 'neutral', icon: 'status-paused') + %h2.gl-m-0 + = integration.title + - if integration.operating? + = render Pajamas::BadgeComponent.new(_('Active'), variant: 'success', icon: 'status-success') + - elsif integration.persisted? + = render Pajamas::BadgeComponent.new(_('Inactive'), variant: 'neutral', icon: 'status-paused') = render 'shared/integration_settings', integration: integration - if lookup_context.template_exists?('show', "shared/integrations/#{integration.to_param}", true) diff --git a/app/views/projects/settings/merge_requests/show.html.haml b/app/views/projects/settings/merge_requests/show.html.haml index 01af028f30c..e877be704a2 100644 --- a/app/views/projects/settings/merge_requests/show.html.haml +++ b/app/views/projects/settings/merge_requests/show.html.haml @@ -2,7 +2,7 @@ - page_title _('Merge requests') - @force_desktop_expanded_sidebar = true -%section.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings.expanded{ class: [('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)], data: { qa_selector: 'merge_request_settings_content' } } +%section.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings.expanded{ class: [('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)], data: { testid: 'merge-request-settings-content' } } .settings-header %h4= _('Merge requests') = render_if_exists 'projects/settings/merge_requests/merge_request_settings_description_text' diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml index 58e86ebffa0..7ff798d7324 100644 --- a/app/views/projects/snippets/show.html.haml +++ b/app/views/projects/snippets/show.html.haml @@ -6,6 +6,6 @@ #js-snippet-view{ data: { 'snippet-gid': @snippet.to_global_id, 'report-abuse-path': snippet_report_abuse_path(@snippet), 'can-report-spam': @snippet.submittable_as_spam_by?(current_user).to_s } } .gl-px-0.gl-py-2 - = render 'award_emoji/awards_block', awardable: @snippet, inline: true, api_awards_path: project_snippets_award_api_path(@snippet) + = render 'award_emoji/awards_block', awardable: @snippet, inline: true, api_awards_path: project_snippets_award_api_path(@snippet), new_custom_emoji_path: new_custom_emoji_path(@project.group) #notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => true diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index a4ed19c2fc9..37f27aa7caf 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -1,5 +1,7 @@ +- add_page_specific_style 'page_bundles/projects' + .tree-ref-container.gl-display-flex.gl-flex-wrap.gl-gap-2.mb-2.mb-md-0 - .tree-ref-holder.gl-max-w-26{ data: { qa_selector: 'ref_dropdown_container' } } + .tree-ref-holder.gl-max-w-26{ data: { testid: 'ref-dropdown-container' } } #js-tree-ref-switcher{ data: { project_id: @project.id, ref_type: @ref_type.to_s, project_root_path: project_path(@project) } } #js-repo-breadcrumb{ data: breadcrumb_data_attributes } @@ -10,6 +12,7 @@ = render_if_exists 'projects/tree/lock_link' #js-tree-history-link{ data: { history_link: project_commits_path(@project, @ref) } } + = render 'projects/buttons/compare', project: @project, ref: @ref, root_ref: @repository&.root_ref = render 'projects/find_file_link' = render 'shared/web_ide_button', blob: nil = render 'projects/buttons/download', project: @project, ref: @ref diff --git a/app/views/projects/work_items/index.html.haml b/app/views/projects/work_items/show.html.haml index 01b27eed267..7e0bddf1b5d 100644 --- a/app/views/projects/work_items/index.html.haml +++ b/app/views/projects/work_items/show.html.haml @@ -1,4 +1,4 @@ -- page_title "##{request.params['work_items_path']}" +- page_title "##{request.params['iid']}" - add_to_breadcrumbs _("Issues"), project_issues_path(@project) - add_page_specific_style 'page_bundles/work_items' - @gfm_form = true diff --git a/app/views/protected_branches/shared/_dropdown.html.haml b/app/views/protected_branches/shared/_dropdown.html.haml index c5dbf8991cd..678c2a1631d 100644 --- a/app/views/protected_branches/shared/_dropdown.html.haml +++ b/app/views/protected_branches/shared/_dropdown.html.haml @@ -6,13 +6,13 @@ options: { toggle_class: "js-protected-branch-select js-filter-submit wide monospace #{toggle_classes}", filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", - dropdown_qa_selector: "protected_branch_dropdown_content", + dropdown_testid: "protected-branch-dropdown-content", placeholder: _("Search protected branches"), footer_content: true, data: { show_no: true, show_any: true, show_upcoming: true, selected: params[:protected_branch_name], project_id: @project.try(:id), - qa_selector: "protected_branch_dropdown" } }) do + testid: "protected-branch-dropdown" } }) do %ul.dropdown-footer-list %li diff --git a/app/views/registrations/welcome/show.html.haml b/app/views/registrations/welcome/show.html.haml deleted file mode 100644 index 0f161855cdb..00000000000 --- a/app/views/registrations/welcome/show.html.haml +++ /dev/null @@ -1,43 +0,0 @@ -- @html_class = "subscriptions-layout-html" -- page_title _('Your profile') -- add_page_specific_style 'page_bundles/signup' -- add_page_specific_style 'page_bundles/login' -- gitlab_experience_text = _('To personalize your GitLab experience, we\'d like to know a bit more about you') -- content_for :page_specific_javascripts do - = render "layouts/google_tag_manager_head" - = render "layouts/one_trust" - = render "layouts/bizible" -= render "layouts/google_tag_manager_body" - -.row.gl-flex-grow-1 - .d-flex.gl-flex-direction-column.gl-align-items-center.gl-w-full.gl-px-5.gl-pb-5 - .edit-profile.login-page.d-flex.flex-column.gl-align-items-center - %h2.gl-text-center= html_escape(_('Welcome to GitLab,%{br_tag}%{name}!')) % { name: html_escape(current_user.first_name), br_tag: '<br/>'.html_safe } - - if Gitlab.com? - %p.gl-text-center= html_escape(_('%{gitlab_experience_text}. We won\'t share this information with anyone.')) % { gitlab_experience_text: gitlab_experience_text } - - else - %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(welcome_update_params), - html: { class: 'gl-w-full! gl-p-5 js-users-signup-welcome', - 'aria-live' => 'assertive', - data: { testid: 'welcome-form' } }) do |f| - = render Pajamas::CardComponent.new do |c| - - c.with_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', 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_if_exists "registrations/welcome/opt_in_to_email" - .row - .form-group.col-sm-12.gl-mb-0 - - if partial_exists? "registrations/welcome/button" - = render "registrations/welcome/button" - - else - = render Pajamas::ButtonComponent.new(block: true, type: :submit, variant: :confirm, button_options: { class: 'gl-mb-0', data: { qa_selector: 'get_started_button' }}) do - = _('Get started!') diff --git a/app/views/search/results/_error.html.haml b/app/views/search/results/_error.html.haml index f0d9283c620..33e74b08dde 100644 --- a/app/views/search/results/_error.html.haml +++ b/app/views/search/results/_error.html.haml @@ -1,7 +1,7 @@ .gl-display-flex.gl-flex-direction-column.gl-align-items-center %div .svg-content.svg-150 - = image_tag 'illustrations/search-timeout-md.svg' + = image_tag 'illustrations/empty-state/empty-search-md.svg' %div %h4.gl-text-center.gl-font-weight-bold= s_('SearchError|A search query problem has occurred') %p.gl-text-center= s_('SearchError|To resolve the problem, check the query syntax and try again.') diff --git a/app/views/search/results/_timeout.html.haml b/app/views/search/results/_timeout.html.haml index 740e2bedd54..530096ead43 100644 --- a/app/views/search/results/_timeout.html.haml +++ b/app/views/search/results/_timeout.html.haml @@ -1,7 +1,7 @@ .gl-display-flex.gl-flex-direction-column.gl-align-items-center %div .svg-content.svg-150 - = image_tag 'illustrations/search-timeout-md.svg' + = image_tag 'illustrations/empty-state/empty-search-md.svg' %div %h4.gl-text-center.gl-font-weight-bold= _('Your search timed out') %p.gl-text-center= _('To resolve this, try to:') diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml index 2fd6e4a5ca5..9c1f4c8643f 100644 --- a/app/views/search/show.html.haml +++ b/app/views/search/show.html.haml @@ -21,7 +21,7 @@ %h1.page-title.gl-font-size-h-display.gl-mr-5= _('Search') = render_if_exists 'search/form_elasticsearch', attrs: { class: 'mb-2 mb-sm-0 align-self-center' } -#js-search-topbar{ data: { "group-initial-json": group_attributes.to_json, "project-initial-json": project_attributes.to_json, "elasticsearch-enabled": @search_service_presenter.advanced_search_enabled?.to_s, "default-branch-name": @project&.default_branch } } +#js-search-topbar{ data: { "group-initial-json": group_attributes.to_json, "project-initial-json": project_attributes.to_json, "default-branch-name": @project&.default_branch } } .results.gl-lg-display-flex.gl-mt-0 #js-search-sidebar{ class: search_bar_classes, data: { navigation_json: search_navigation_json, search_type: search_service.search_type } } - if @search_term diff --git a/app/views/shared/_broadcast_message.html.haml b/app/views/shared/_broadcast_message.html.haml index 2f470d5ef53..12571ef5b73 100644 --- a/app/views/shared/_broadcast_message.html.haml +++ b/app/views/shared/_broadcast_message.html.haml @@ -24,7 +24,7 @@ - else - notification_class = "js-broadcast-notification-#{message.id}" - notification_class << ' preview' if preview - .gl-broadcast-message.broadcast-notification-message.gl-mt-3{ role: "alert", class: notification_class, data: { qa_selector: 'broadcast_notification_container' } } + .gl-broadcast-message.broadcast-notification-message.gl-mt-3{ role: "alert", class: notification_class, data: { testid: 'broadcast-notification-container' } } .gl-broadcast-message-content .gl-broadcast-message-icon = sprite_icon(icon_name, css_class: 'vertical-align-text-top') @@ -38,4 +38,4 @@ = render Pajamas::ButtonComponent.new(category: :tertiary, icon: 'close', size: :small, - button_options: { class: 'js-dismiss-current-broadcast-notification', 'aria-label': _('Close'), data: { id: message.id, expire_date: message.ends_at.iso8601, qa_selector: 'close_button' } }) + button_options: { class: 'js-dismiss-current-broadcast-notification', 'aria-label': _('Close'), data: { id: message.id, expire_date: message.ends_at.iso8601, testid: 'close-button' } }) diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml index dde4ec3cf52..4b39ec52837 100644 --- a/app/views/shared/_clone_panel.html.haml +++ b/app/views/shared/_clone_panel.html.haml @@ -5,18 +5,18 @@ %span.js-clone-dropdown-label = enabled_protocol_button(container, enabled_protocol) - else - %a#clone-dropdown.input-group-text.gl-button.btn.btn-default.btn-icon.clone-dropdown-btn{ href: '#', data: { toggle: 'dropdown', qa_selector: 'clone_dropdown' } } + %a#clone-dropdown.input-group-text.gl-button.btn.btn-default.btn-icon.clone-dropdown-btn{ href: '#', data: { toggle: 'dropdown', testid: 'clone-dropdown' } } %span.js-clone-dropdown-label = default_clone_protocol.upcase = sprite_icon('chevron-down', css_class: 'gl-icon') - %ul.dropdown-menu.dropdown-menu-selectable.clone-options-dropdown{ data: { qa_selector: 'clone_dropdown_content' } } + %ul.dropdown-menu.dropdown-menu-selectable.clone-options-dropdown{ data: { testid: 'clone-dropdown-content' } } %li = ssh_clone_button(container) %li = http_clone_button(container) = render_if_exists 'shared/kerberos_clone_button', container: container - = text_field_tag :clone_url, default_url_to_repo(container), class: "js-select-on-focus btn gl-button", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'clone_url_content' } + = text_field_tag :clone_url, default_url_to_repo(container), class: "js-select-on-focus btn gl-button", readonly: true, aria: { label: _('Repository clone URL') }, data: { testid: 'clone-url-content' } .input-group-append = clipboard_button(target: '#clone_url', title: _("Copy URL"), variant: :default, category: :primary, size: :medium) diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index bb21c4a28fd..ceacd5c48cd 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -6,7 +6,7 @@ - toggle_subscription_path = toggle_subscription_label_path(label, @project) if current_user - tooltip_title = label_status_tooltip(label, status) if status -%li.label-list-item.gl-list-style-none{ id: label_css_id, data: { id: label.id } } +%li.js-label-list-item.gl-list-style-none.gl-border-b.gl-last-of-type-border-b-0{ id: label_css_id, data: { id: label.id } } .label-content.gl-pl-5.gl-pr-3.gl-py-4.gl-rounded-base{ class: "#{ 'gl-py-3' if force_priority }" } = render "shared/label_row", label: label, force_priority: force_priority %ul.label-actions-list diff --git a/app/views/shared/_logo.svg b/app/views/shared/_logo.svg index dfc35856366..df65de87fb9 100644 --- a/app/views/shared/_logo.svg +++ b/app/views/shared/_logo.svg @@ -1,4 +1,4 @@ -<svg role="img" class="tanuki-logo" width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<svg aria-hidden="true" role="img" class="tanuki-logo" width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path class="tanuki-shape tanuki" d="m24.507 9.5-.034-.09L21.082.562a.896.896 0 0 0-1.694.091l-2.29 7.01H7.825L5.535.653a.898.898 0 0 0-1.694-.09L.451 9.411.416 9.5a6.297 6.297 0 0 0 2.09 7.278l.012.01.03.022 5.16 3.867 2.56 1.935 1.554 1.176a1.051 1.051 0 0 0 1.268 0l1.555-1.176 2.56-1.935 5.197-3.89.014-.01A6.297 6.297 0 0 0 24.507 9.5Z" fill="#E24329"/> <path class="tanuki-shape right-cheek" d="m24.507 9.5-.034-.09a11.44 11.44 0 0 0-4.56 2.051l-7.447 5.632 4.742 3.584 5.197-3.89.014-.01A6.297 6.297 0 0 0 24.507 9.5Z" diff --git a/app/views/shared/_logo_with_black_text.svg b/app/views/shared/_logo_with_black_text.svg index f5b0b70618b..bf59618bade 100644 --- a/app/views/shared/_logo_with_black_text.svg +++ b/app/views/shared/_logo_with_black_text.svg @@ -1,4 +1,4 @@ -<svg class="tanuki-logo" width="111" height="24" viewBox="0 0 111 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<svg aria-hidden="true" class="tanuki-logo" width="111" height="24" viewBox="0 0 111 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path class="logo-text" d="M44.814 9.042h3.645c-.608-3.875-3.963-6.574-8.33-6.574-5.166 0-9.043 3.798-9.043 10.16 0 6.248 3.703 10.123 9.15 10.123 4.887 0 8.386-3.144 8.386-8.234v-2.37h-8.01v2.794h4.55c-.058 2.816-1.938 4.599-4.908 4.599-3.305 0-5.57-2.477-5.57-6.95 0-4.445 2.303-6.913 5.494-6.913 2.38 0 4.01 1.272 4.636 3.365Zm6.218 13.438h3.49V7.68h-3.49v14.8Zm1.76-17.151c1.109 0 2.014-.85 2.014-1.89s-.905-1.9-2.014-1.9c-1.109 0-2.024.849-2.024 1.9s.9 1.89 2.017 1.89h.007ZM64.971 7.68H62.05V4.126h-3.49v3.556h-2.1v2.699h2.1v8.233c-.018 2.786 2.007 4.16 4.628 4.079a7.089 7.089 0 0 0 2.055-.348l-.59-2.73a4.247 4.247 0 0 1-1.02.137c-.878 0-1.582-.309-1.582-1.717v-7.662h2.921V7.68Zm2.701 14.8h12.272v-2.998H71.25V2.737h-3.578V22.48Zm18.957.3c2.323 0 3.71-1.09 4.347-2.333h.115v2.033h3.36v-9.91c0-3.913-3.19-5.09-6.016-5.09-3.113 0-5.504 1.388-6.275 4.087l3.26.464c.345-1.013 1.329-1.88 3.04-1.88 1.62 0 2.506.829 2.506 2.285v.057c0 1.002-1.05 1.051-3.664 1.33-2.872.309-5.619 1.166-5.619 4.502-.01 2.912 2.12 4.455 4.946 4.455Zm1.147-2.56c-1.456 0-2.498-.666-2.498-1.948 0-1.34 1.167-1.899 2.72-2.121.917-.125 2.75-.357 3.2-.722v1.744c.01 1.643-1.321 3.042-3.422 3.042v.005Zm9.244 2.26h3.433v-2.332h.201c.551 1.08 1.698 2.593 4.244 2.593 3.489 0 6.102-2.768 6.102-7.644 0-4.936-2.69-7.616-6.112-7.616-2.613 0-3.702 1.57-4.234 2.641h-.147V2.737h-3.486V22.48Zm3.423-7.403c0-2.88 1.234-4.734 3.48-4.734 2.323 0 3.52 1.976 3.52 4.734 0 2.759-1.214 4.8-3.52 4.8-2.227 0-3.48-1.928-3.48-4.8Z" fill="#171321"/> <path class="tanuki-shape tanuki" d="m24.507 9.5-.034-.09L21.082.562a.896.896 0 0 0-1.694.091l-2.29 7.01H7.825L5.535.653a.898.898 0 0 0-1.694-.09L.451 9.411.416 9.5a6.297 6.297 0 0 0 2.09 7.278l.012.01.03.022 5.16 3.867 2.56 1.935 1.554 1.176a1.051 1.051 0 0 0 1.268 0l1.555-1.176 2.56-1.935 5.197-3.89.014-.01A6.297 6.297 0 0 0 24.507 9.5Z" diff --git a/app/views/shared/_logo_with_white_text.svg b/app/views/shared/_logo_with_white_text.svg index d0067538058..b47c7c55d59 100644 --- a/app/views/shared/_logo_with_white_text.svg +++ b/app/views/shared/_logo_with_white_text.svg @@ -1,4 +1,4 @@ -<svg class="tanuki-logo" width="111" height="24" viewBox="0 0 111 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<svg aria-hidden="true" class="tanuki-logo" width="111" height="24" viewBox="0 0 111 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path class="logo-text" d="M44.814 9.042h3.645c-.608-3.875-3.963-6.574-8.33-6.574-5.166 0-9.043 3.798-9.043 10.16 0 6.248 3.703 10.123 9.15 10.123 4.887 0 8.386-3.144 8.386-8.234v-2.37h-8.01v2.794h4.55c-.058 2.816-1.938 4.599-4.908 4.599-3.305 0-5.57-2.477-5.57-6.95 0-4.445 2.303-6.913 5.494-6.913 2.38 0 4.01 1.272 4.636 3.365Zm6.218 13.438h3.49V7.68h-3.49v14.8Zm1.76-17.151c1.109 0 2.014-.85 2.014-1.89s-.905-1.9-2.014-1.9c-1.109 0-2.024.849-2.024 1.9s.9 1.89 2.017 1.89h.007ZM64.971 7.68H62.05V4.126h-3.49v3.556h-2.1v2.699h2.1v8.233c-.018 2.786 2.007 4.16 4.628 4.079a7.089 7.089 0 0 0 2.055-.348l-.59-2.73a4.247 4.247 0 0 1-1.02.137c-.878 0-1.582-.309-1.582-1.717v-7.662h2.921V7.68Zm2.701 14.8h12.272v-2.998H71.25V2.737h-3.578V22.48Zm18.957.3c2.323 0 3.71-1.09 4.347-2.333h.115v2.033h3.36v-9.91c0-3.913-3.19-5.09-6.016-5.09-3.113 0-5.504 1.388-6.275 4.087l3.26.464c.345-1.013 1.329-1.88 3.04-1.88 1.62 0 2.506.829 2.506 2.285v.057c0 1.002-1.05 1.051-3.664 1.33-2.872.309-5.619 1.166-5.619 4.502-.01 2.912 2.12 4.455 4.946 4.455Zm1.147-2.56c-1.456 0-2.498-.666-2.498-1.948 0-1.34 1.167-1.899 2.72-2.121.917-.125 2.75-.357 3.2-.722v1.744c.01 1.643-1.321 3.042-3.422 3.042v.005Zm9.244 2.26h3.433v-2.332h.201c.551 1.08 1.698 2.593 4.244 2.593 3.489 0 6.102-2.768 6.102-7.644 0-4.936-2.69-7.616-6.112-7.616-2.613 0-3.702 1.57-4.234 2.641h-.147V2.737h-3.486V22.48Zm3.423-7.403c0-2.88 1.234-4.734 3.48-4.734 2.323 0 3.52 1.976 3.52 4.734 0 2.759-1.214 4.8-3.52 4.8-2.227 0-3.48-1.928-3.48-4.8Z" fill="#fff"/> <path class="tanuki-shape tanuki" d="m24.507 9.5-.034-.09L21.082.562a.896.896 0 0 0-1.694.091l-2.29 7.01H7.825L5.535.653a.898.898 0 0 0-1.694-.09L.451 9.411.416 9.5a6.297 6.297 0 0 0 2.09 7.278l.012.01.03.022 5.16 3.867 2.56 1.935 1.554 1.176a1.051 1.051 0 0 0 1.268 0l1.555-1.176 2.56-1.935 5.197-3.89.014-.01A6.297 6.297 0 0 0 24.507 9.5Z" diff --git a/app/views/shared/_zen.html.haml b/app/views/shared/_zen.html.haml index 05bee9e4d42..039db4eec59 100644 --- a/app/views/shared/_zen.html.haml +++ b/app/views/shared/_zen.html.haml @@ -2,7 +2,7 @@ - current_text ||= nil - supports_autocomplete = local_assigns.fetch(:supports_autocomplete, true) - supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false) -- qa_selector = local_assigns.fetch(:qa_selector, '') +- testid = local_assigns.fetch(:testid, '') - autofocus = local_assigns.fetch(:autofocus, false) .zen-backdrop @@ -14,9 +14,9 @@ dir: 'auto', data: { supports_quick_actions: supports_quick_actions, supports_autocomplete: supports_autocomplete, - qa_selector: qa_selector, + testid: testid, autofocus: autofocus } - else - = text_area_tag attr, current_text, data: { qa_selector: qa_selector }, class: classes, placeholder: placeholder + = text_area_tag attr, current_text, data: { testid: testid }, class: classes, placeholder: placeholder %a.zen-control.zen-control-leave.js-zen-leave.gl-text-gray-500{ href: "#" } = sprite_icon('minimize') diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml index e5aa4c58da1..882730f536d 100644 --- a/app/views/shared/boards/_show.html.haml +++ b/app/views/shared/boards/_show.html.haml @@ -15,5 +15,6 @@ - page_title("#{board.name}", _("Boards")) - add_page_specific_style 'page_bundles/boards' +- add_page_specific_style 'page_bundles/labels' #js-issuable-board-app{ data: board_data } diff --git a/app/views/shared/doorkeeper/applications/_show.html.haml b/app/views/shared/doorkeeper/applications/_show.html.haml index d309a335166..9bf29f3ce64 100644 --- a/app/views/shared/doorkeeper/applications/_show.html.haml +++ b/app/views/shared/doorkeeper/applications/_show.html.haml @@ -8,7 +8,7 @@ %td .clipboard-group .input-group - %input.label.label-monospace.monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true, data: { qa_selector: 'application_id_field' } } + %input.label.label-monospace.monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true, data: { testid: 'application-id-field' } } .input-group-append = clipboard_button(target: "#application_id", title: _("Copy ID"), category: :primary, size: :medium) %tr @@ -48,4 +48,4 @@ = render 'shared/doorkeeper/applications/delete_form', path: delete_path -# Create a hidden field to save the ID of application created -= hidden_field_tag(:id_of_application, @application.id, data: { qa_selector: 'id_of_application_field' }) += hidden_field_tag(:id_of_application, @application.id, data: { testid: 'id-of-application-field' }) diff --git a/app/views/shared/empty_states/_labels.html.haml b/app/views/shared/empty_states/_labels.html.haml index 4d2127c0161..9dd9cdcd051 100644 --- a/app/views/shared/empty_states/_labels.html.haml +++ b/app/views/shared/empty_states/_labels.html.haml @@ -1,6 +1,6 @@ .row.empty-state.labels .col-12 - .svg-content.svg-150{ data: { qa_selector: 'label_svg_content' } } + .svg-content.svg-150{ data: { testid: 'label-svg-content' } } = image_tag 'illustrations/empty-state/empty-labels-md.svg' .col-12 .text-content diff --git a/app/views/shared/empty_states/_merge_requests.html.haml b/app/views/shared/empty_states/_merge_requests.html.haml index 5b377818c6e..5a96b51be61 100644 --- a/app/views/shared/empty_states/_merge_requests.html.haml +++ b/app/views/shared/empty_states/_merge_requests.html.haml @@ -5,7 +5,7 @@ - is_closed_state = params[:state] == 'closed' - can_create_merge_request = merge_request_source_project_for_project(@project) -.row.empty-state.merge-requests +.row.empty-state.merge-requests{ data: { testid: 'issuable-empty-state' } } .col-12 .svg-content.svg-150 = image_tag 'illustrations/empty-state/empty-merge-requests-md.svg', { auto_dark: true } @@ -37,4 +37,4 @@ = _("Interested parties can even contribute by pushing commits if they want to.") - if button_path .text-center - = link_button_to _('New merge request'), button_path, title: _('New merge request'), id: 'new_merge_request_link', data: { qa_selector: "new_merge_request_button" }, variant: :confirm + = link_button_to _('New merge request'), button_path, title: _('New merge request'), id: 'new_merge_request_link', data: { testid: "new-merge-request-button" }, variant: :confirm diff --git a/app/views/shared/empty_states/_priority_labels.html.haml b/app/views/shared/empty_states/_priority_labels.html.haml index 688df1705aa..ad9dee10205 100644 --- a/app/views/shared/empty_states/_priority_labels.html.haml +++ b/app/views/shared/empty_states/_priority_labels.html.haml @@ -1,5 +1,5 @@ .text-center.gl-mt-1.gl-mb-5 - .svg-content{ data: { qa_selector: 'label_svg_content' } } + .svg-content{ data: { testid: 'label-svg-content' } } = image_tag 'illustrations/empty-state/empty-labels-starred-md.svg' - if can?(current_user, :admin_label, @project) %h5.gl-my-0 diff --git a/app/views/shared/empty_states/_snippets.html.haml b/app/views/shared/empty_states/_snippets.html.haml index 6fe36d75453..a2457fb0810 100644 --- a/app/views/shared/empty_states/_snippets.html.haml +++ b/app/views/shared/empty_states/_snippets.html.haml @@ -2,7 +2,7 @@ .row.empty-state .col-12 - .svg-content.svg-150{ data: { qa_selector: 'svg_content' } } + .svg-content.svg-150{ data: { testid: 'svg-content' } } = image_tag 'illustrations/empty-state/empty-snippets-md.svg' .text-content.gl-text-center.gl-pt-0 - if current_user @@ -12,7 +12,7 @@ = s_('SnippetsEmptyState|Store, share, and embed small pieces of code and text.') .gl-mt-3< - if button_path - = link_button_to s_('SnippetsEmptyState|New snippet'), button_path, title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link', data: { qa_selector: 'create_first_snippet_link' }, variant: :confirm + = link_button_to s_('SnippetsEmptyState|New snippet'), button_path, title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link', data: { testid: 'create-first-snippet-link' }, variant: :confirm = link_button_to s_('SnippetsEmptyState|Documentation'), help_page_path('user/snippets.md'), title: s_('SnippetsEmptyState|Documentation') - else %h4.gl-text-center= s_('SnippetsEmptyState|There are no snippets to show.') diff --git a/app/views/shared/empty_states/_wikis.html.haml b/app/views/shared/empty_states/_wikis.html.haml index 567c4a2d444..e152390b0df 100644 --- a/app/views/shared/empty_states/_wikis.html.haml +++ b/app/views/shared/empty_states/_wikis.html.haml @@ -4,7 +4,7 @@ - if !hide_create && can?(current_user, :create_wiki, @wiki.container) - create_path = wiki_page_path(@wiki, params[:id], view: 'create') - - create_link = link_button_to s_('WikiEmpty|Create your first page'), create_path, title: s_('WikiEmpty|Create your first page'), data: { qa_selector: 'create_first_page_link' }, variant: :confirm + - create_link = link_button_to s_('WikiEmpty|Create your first page'), create_path, title: s_('WikiEmpty|Create your first page'), data: { testid: 'create-first-page-link' }, variant: :confirm = render layout: layout_path, locals: { image_path: 'illustrations/empty-state/empty-wiki-md.svg' } do %h4.text-left diff --git a/app/views/shared/empty_states/_wikis_layout.html.haml b/app/views/shared/empty_states/_wikis_layout.html.haml index 03054c959fd..831bdcac073 100644 --- a/app/views/shared/empty_states/_wikis_layout.html.haml +++ b/app/views/shared/empty_states/_wikis_layout.html.haml @@ -1,6 +1,6 @@ .row.empty-state.empty-state-wiki .col-12 - .svg-content.svg-150{ data: { qa_selector: 'svg_content' } } + .svg-content.svg-150{ data: { testid: 'svg-content' } } = image_tag image_path .col-12 .text-content.text-center diff --git a/app/views/shared/file_hooks/_index.html.haml b/app/views/shared/file_hooks/_index.html.haml index 9f1b11d6ab5..478f047e9cc 100644 --- a/app/views/shared/file_hooks/_index.html.haml +++ b/app/views/shared/file_hooks/_index.html.haml @@ -23,7 +23,7 @@ - if file_hooks.any? %ul.content-list{ class: 'gl-my-n3!' } - file_hooks.each do |file| - %li.label-list-item + %li.gl-border-b.gl-last-of-type-border-b-0 .monospace = File.basename(file) - else diff --git a/app/views/shared/integrations/edit.html.haml b/app/views/shared/integrations/edit.html.haml index 9d613d2ad94..3361dfba5d2 100644 --- a/app/views/shared/integrations/edit.html.haml +++ b/app/views/shared/integrations/edit.html.haml @@ -2,8 +2,10 @@ - breadcrumb_title @integration.title - page_title @integration.title, _('Integrations') -%h2.gl-mb-4 - = @integration.title +.gl-display-flex.gl-align-items-center.gl-gap-3.gl-mt-5 + = render Pajamas::AvatarComponent.new(@integration, size: 64, alt: '') + %h2.gl-m-0 + = @integration.title = render 'shared/integrations/tabs', integration: @integration, active_tab: 'edit' do = render 'shared/integration_settings', integration: @integration diff --git a/app/views/shared/integrations/mattermost_slash_commands/_detailed_help.html.haml b/app/views/shared/integrations/mattermost_slash_commands/_detailed_help.html.haml index 5aaae5eb4ec..f1e2b8fff47 100644 --- a/app/views/shared/integrations/mattermost_slash_commands/_detailed_help.html.haml +++ b/app/views/shared/integrations/mattermost_slash_commands/_detailed_help.html.haml @@ -48,7 +48,7 @@ .form-group = label_tag :request_url, s_('MattermostService|Request URL'), class: 'col-12 col-form-label label-bold' .col-12.input-group - = text_field_tag :request_url, service_trigger_url(integration), class: 'form-control form-control-sm', readonly: 'readonly' + = text_field_tag :request_url, integration_trigger_url(integration), class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#request_url', category: :primary, size: :medium) diff --git a/app/views/shared/integrations/slack_slash_commands/_help.html.haml b/app/views/shared/integrations/slack_slash_commands/_help.html.haml index defaf50efea..fd30c5b0da3 100644 --- a/app/views/shared/integrations/slack_slash_commands/_help.html.haml +++ b/app/views/shared/integrations/slack_slash_commands/_help.html.haml @@ -38,9 +38,9 @@ .form-group = label_tag :url, 'URL', class: 'col-12 col-form-label label-bold' .col-12.input-group - = text_field_tag :url, service_trigger_url(integration), class: 'form-control form-control-sm', readonly: 'readonly' + = text_field_tag :url, integration_trigger_url(integration), class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append - = deprecated_clipboard_button(target: '#url', class: 'input-group-text') + = clipboard_button(target: '#url', category: :primary, size: :medium) .form-group = label_tag nil, _('Method'), class: 'col-12 col-form-label label-bold' @@ -51,7 +51,7 @@ .col-12.input-group = text_field_tag :customize_name, 'GitLab', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append - = deprecated_clipboard_button(target: '#customize_name', class: 'input-group-text') + = clipboard_button(target: '#customize_name', category: :primary, size: :medium) .form-group = label_tag nil, _('Customize icon'), class: 'col-12 col-form-label label-bold' @@ -68,21 +68,21 @@ .col-12.input-group = text_field_tag :autocomplete_description, run_actions_text.html_safe, class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append - = deprecated_clipboard_button(target: '#autocomplete_description', class: 'input-group-text') + = clipboard_button(target: '#autocomplete_description', category: :primary, size: :medium) .form-group = label_tag :autocomplete_usage_hint, _('Autocomplete usage hint'), class: 'col-12 col-form-label label-bold' .col-12.input-group = text_field_tag :autocomplete_usage_hint, '[help]', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append - = deprecated_clipboard_button(target: '#autocomplete_usage_hint', class: 'input-group-text') + = clipboard_button(target: '#autocomplete_usage_hint', category: :primary, size: :medium) .form-group = label_tag :descriptive_label, _('Descriptive label'), class: 'col-12 col-form-label label-bold' .col-12.input-group = text_field_tag :descriptive_label, _('Perform common operations on GitLab project'), class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append - = deprecated_clipboard_button(target: '#descriptive_label', class: 'input-group-text') + = clipboard_button(target: '#descriptive_label', category: :primary, size: :medium) %hr diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml index 3c4ee01d04f..286b3c7f66f 100644 --- a/app/views/shared/issuable/_label_dropdown.html.haml +++ b/app/views/shared/issuable/_label_dropdown.html.haml @@ -1,3 +1,4 @@ +- add_page_specific_style 'page_bundles/labels' - project = @target_project || @project - edit_context = local_assigns.fetch(:edit_context, nil) || project - show_create = local_assigns.fetch(:show_create, true) @@ -11,7 +12,7 @@ - dropdown_title = local_assigns.fetch(:dropdown_title, _('Filter by label')) - dropdown_data = label_dropdown_data(edit_context, labels: labels_filter_path_with_defaults(only_group_labels: edit_context.is_a?(Group)), default_label: _('Labels')) -- dropdown_data.merge!(data_options, qa_selector: "issuable_label_dropdown") +- dropdown_data.merge!(data_options, testid: "issuable-label-dropdown") - label_name = local_assigns.fetch(:label_name, _('Labels')) - no_default_styles = local_assigns.fetch(:no_default_styles, false) - classes << 'js-extra-options' if extra_options diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 93e1a53ccb4..1392c7ab89f 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -11,24 +11,28 @@ - is_merge_request = issuable_type === 'merge_request' - moved_sidebar_enabled = moved_mr_sidebar_enabled? - is_merge_request_with_flag = is_merge_request && moved_sidebar_enabled +- add_page_specific_style 'page_bundles/labels' %aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { always_show_toggle: true, signed: { in: signed_in }, issuable_type: issuable_type }, class: "#{sidebar_gutter_collapsed_class(is_merge_request_with_flag)} #{'right-sidebar-merge-requests' if is_merge_request_with_flag}", 'aria-live' => 'polite', 'aria-label': issuable_type } .issuable-sidebar{ class: "#{'is-merge-request' if is_merge_request_with_flag}" } - .issuable-sidebar-header{ class: "#{'gl-pb-2! gl-md-display-flex gl-justify-content-end gl-lg-display-none!' if is_merge_request_with_flag}" } + .issuable-sidebar-header{ class: "gl-pb-4! #{'gl-pb-2! gl-md-display-flex gl-justify-content-end gl-lg-display-none!' if is_merge_request_with_flag}" } = render Pajamas::ButtonComponent.new(button_options: { class: "gutter-toggle float-right js-sidebar-toggle has-tooltip gl-shadow-none! #{'gl-display-block' if moved_sidebar_enabled}", type: 'button', 'aria-label' => _('Toggle sidebar'), title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } }) do = sidebar_gutter_toggle_icon - - if signed_in && !is_merge_request_with_flag - .js-sidebar-todo-widget-root{ data: { project_path: issuable_sidebar[:project_full_path], iid: issuable_sidebar[:iid], id: issuable_sidebar[:id] } } + - if signed_in + - if !is_merge_request_with_flag + .js-sidebar-todo-widget-root{ data: { project_path: issuable_sidebar[:project_full_path], iid: issuable_sidebar[:iid], id: issuable_sidebar[:id] } } + - if notifications_todos_buttons_enabled? + .js-sidebar-subscriptions-widget-root = 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', testid: 'assignee-block-container' } } + .block.assignee{ class: "#{'gl-mt-3' if !signed_in && moved_sidebar_enabled}", data: { testid: 'assignee-block-container' } } = render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees, signed_in: signed_in - if issuable_sidebar[:supports_severity] .js-sidebar-severity-widget-root - if reviewers - .block.reviewer{ data: { qa_selector: 'reviewers_block_container' } } + .block.reviewer{ data: { testid: 'reviewers-block-container' } } = render "shared/issuable/sidebar_reviewers", issuable_sidebar: issuable_sidebar, reviewers: reviewers, signed_in: signed_in - if issuable_sidebar[:supports_escalation] @@ -42,7 +46,7 @@ .js-sidebar-labels-widget-root{ data: sidebar_labels_data(issuable_sidebar, @project) } - if issuable_sidebar[:supports_milestone] - .block.milestone{ data: { qa_selector: 'milestone_block', testid: 'sidebar-milestones' } } + .block.milestone{ data: { testid: 'sidebar-milestones' } } .js-sidebar-milestone-widget-root{ data: { can_edit: can_edit_issuable.to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } } - if in_group_context_with_iterations @@ -88,11 +92,11 @@ - if is_merge_request && !moved_sidebar_enabled .sub-block.js-sidebar-source-branch .sidebar-collapsed-icon.js-dont-change-state - = deprecated_clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport', class: 'btn-clipboard gl-button btn-default-tertiary btn-icon btn-sm js-source-branch-copy') + = clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport', class: 'js-source-branch-copy') .gl-display-flex.gl-align-items-center.gl-justify-content-space-between.gl-mb-2.hide-collapsed %span.gl-overflow-hidden.gl-text-overflow-ellipsis.gl-white-space-nowrap = _('Source branch: %{source_branch_open}%{source_branch}%{source_branch_close}').html_safe % { source_branch_open: "<span class='gl-font-monospace' data-testid='ref-name' title='#{html_escape(source_branch)}'>".html_safe, source_branch_close: "</span>".html_safe, source_branch: html_escape(source_branch) } - = deprecated_clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport', class: 'btn-clipboard gl-button btn-default-tertiary btn-icon btn-sm js-source-branch-copy') + = clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport', class: 'js-source-branch-copy') - if show_forwarding_email && !moved_sidebar_enabled .block diff --git a/app/views/shared/issuable/_status_box.html.haml b/app/views/shared/issuable/_status_box.html.haml deleted file mode 100644 index f2e4e22788a..00000000000 --- a/app/views/shared/issuable/_status_box.html.haml +++ /dev/null @@ -1,8 +0,0 @@ -- badge_text = state_name_with_icon(issuable)[0] -- badge_icon = state_name_with_icon(issuable)[1] -- badge_variant = issuable.open? ? :success : issuable.merged? ? :info : :danger -- badge_classes = "js-mr-status-box gl-mr-3 gl-align-self-center" - -= gl_badge_tag({ variant: badge_variant, icon: badge_icon, icon_classes: 'gl-mr-0!' }, { class: badge_classes, data: { project_path: issuable.project.path_with_namespace, iid: issuable.iid, issuable_type: 'merge_request', state: issuable.state } }) do - %span.gl-display-none.gl-sm-display-block.gl-ml-2 - = badge_text diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml index 1da0b82b634..a0ec7ca20ff 100644 --- a/app/views/shared/issuable/form/_metadata.html.haml +++ b/app/views/shared/issuable/form/_metadata.html.haml @@ -3,6 +3,7 @@ - presenter = local_assigns.fetch(:presenter) - has_due_date = issuable.has_attribute?(:due_date) - form = local_assigns.fetch(:form) +- add_page_specific_style 'page_bundles/labels' - if @add_related_issue .form-group @@ -37,15 +38,7 @@ .issuable-form-select-holder = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]" - - if Feature.enabled?(:visible_label_selection_on_metadata, project) - .js-issuable-form-label-selector{ data: issuable_label_selector_data(project, issuable) } - - else - .form-group.row - = form.label :label_ids, _('Labels'), class: "col-12" - = form.hidden_field :label_ids, multiple: true, value: '' - .col-12 - .issuable-form-select-holder - = render "shared/issuable/label_dropdown", classes: ["js-issuable-form-dropdown"], selected: issuable.labels, data_options: { field_name: "#{issuable.class.model_name.param_key}[label_ids][]", show_any: false }, dropdown_title: "Select label" + .js-issuable-form-label-selector{ data: issuable_label_selector_data(project, issuable) } = render_if_exists "shared/issuable/form/merge_request_blocks", issuable: issuable, form: form diff --git a/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml index d25ef3f4e83..1167d68534f 100644 --- a/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml +++ b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml @@ -8,4 +8,4 @@ = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' } = dropdown_tag(users_dropdown_label(issuable.assignees), options: assignees_dropdown_options(issuable.to_ability_name)) - = link_to _('Assign to me'), '#', class: "assign-to-me-link gl-white-space-nowrap gl-md-pl-3 #{'hide' if issuable.assignees.include?(current_user)}", data: { qa_selector: 'assign_to_me_link' } + = link_to _('Assign to me'), '#', class: "assign-to-me-link gl-white-space-nowrap gl-md-pl-3 #{'hide' if issuable.assignees.include?(current_user)}", data: { testid: 'assign-to-me-link' } diff --git a/app/views/shared/issuable/form/_template_selector.html.haml b/app/views/shared/issuable/form/_template_selector.html.haml index c870bb17a85..bad2f4fdfb0 100644 --- a/app/views/shared/issuable/form/_template_selector.html.haml +++ b/app/views/shared/issuable/form/_template_selector.html.haml @@ -3,7 +3,7 @@ - return unless issuable && issuable_templates(ref_project, issuable.to_ability_name).any? .issuable-form-select-holder.selectbox.form-group - .js-issuable-selector-wrap{ data: { issuable_type: issuable.to_ability_name.pluralize, qa_selector: 'template_dropdown' } } + .js-issuable-selector-wrap{ data: { issuable_type: issuable.to_ability_name.pluralize, testid: 'template-dropdown' } } = template_dropdown_tag(issuable) do %ul.dropdown-footer-list %li diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml index 53fbe3dac03..5ac172315be 100644 --- a/app/views/shared/labels/_form.html.haml +++ b/app/views/shared/labels/_form.html.haml @@ -1,3 +1,4 @@ +- add_page_specific_style 'page_bundles/labels' - show_lock_on_merge = local_assigns.fetch(:show_lock_on_merge, false) = gitlab_ui_form_for @label, as: :label, url: url, html: { class: 'label-form js-quick-submit js-requires-input' } do |f| @@ -6,20 +7,20 @@ .form-group.row .col-12 = f.label :title - = f.text_field :title, class: "gl-form-input form-control js-label-title", required: true, autofocus: true, data: { qa_selector: 'label_title_field' } + = f.text_field :title, class: "gl-form-input form-control js-label-title", required: true, autofocus: true, data: { testid: 'label-title-field' } = render_if_exists 'shared/labels/create_label_help_text' .form-group.row .col-12 = f.label :description, _("Description (optional)") - = f.text_area :description, class: "gl-form-input form-control js-quick-submit", rows: 4, data: { qa_selector: 'label_description_field' } + = f.text_area :description, class: "gl-form-input form-control js-quick-submit", rows: 4, data: { testid: 'label-description-field' } .form-group.row .col-12 = f.label :color, _("Background color") .input-group .input-group-prepend %input.label-color-preview.gl-w-7.gl-h-full.gl-border-1.gl-border-solid.gl-border-gray-500.gl-border-r-0.gl-rounded-top-right-none.gl-rounded-bottom-right-none{ type: "color", placeholder: _('Select color') } - = f.text_field :color, class: "gl-form-input form-control", data: { qa_selector: 'label_color_field' } + = f.text_field :color, class: "gl-form-input form-control", data: { testid: 'label-color-field' } .form-text.text-muted = _('Select a color from the color picker or from the presets below.') = render_suggested_colors @@ -35,7 +36,7 @@ - if @label.persisted? = f.submit _('Save changes'), class: 'js-save-button gl-mr-2', pajamas_button: true - else - = f.submit _('Create label'), class: 'js-save-button gl-mr-2', data: { qa_selector: 'label_create_button' }, pajamas_button: true + = f.submit _('Create label'), class: 'js-save-button gl-mr-2', data: { testid: 'label-create-button' }, pajamas_button: true = render Pajamas::ButtonComponent.new(href: back_path) do = _('Cancel') diff --git a/app/views/shared/labels/_nav.html.haml b/app/views/shared/labels/_nav.html.haml index c82a22c73b8..21255e655ea 100644 --- a/app/views/shared/labels/_nav.html.haml +++ b/app/views/shared/labels/_nav.html.haml @@ -14,8 +14,8 @@ = render Pajamas::ButtonComponent.new(icon: 'search', button_options: { type: "submit", "aria-label" => _('Submit search') }) = render 'shared/labels/sort_dropdown' - if labels_or_filters && can_admin_label && @project - = render Pajamas::ButtonComponent.new(variant: :confirm, href: new_project_label_path(@project), button_options: { data: { qa_selector: 'create_new_label_button' } }) do + = render Pajamas::ButtonComponent.new(variant: :confirm, href: new_project_label_path(@project), button_options: { data: { testid: 'create-new-label-button' } }) do = _('New label') - if labels_or_filters && can_admin_label && @group - = render Pajamas::ButtonComponent.new(variant: :confirm, href: new_group_label_path(@group), button_options: { data: { qa_selector: 'create_new_label_button' } }) do + = render Pajamas::ButtonComponent.new(variant: :confirm, href: new_group_label_path(@group), button_options: { data: { testid: 'create-new-label-button' } }) do = _('New label') diff --git a/app/views/shared/milestones/_description.html.haml b/app/views/shared/milestones/_description.html.haml index 3774fb0869f..ff1224f89d0 100644 --- a/app/views/shared/milestones/_description.html.haml +++ b/app/views/shared/milestones/_description.html.haml @@ -1,14 +1,14 @@ .detail-page-description.milestone-detail.gl-py-4 - %h2.gl-m-0{ data: { qa_selector: "milestone_title_content" } } + %h2.gl-m-0{ data: { testid: "milestone-title-content" } } = markdown_field(milestone, :title) - .gl-font-sm.gl-text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ data: { qa_selector: 'milestone_id_content' }, itemprop: 'identifier' } + .gl-font-sm.gl-text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ itemprop: 'identifier' } - if can?(current_user, :read_milestone, @milestone) %span.gl-display-inline-block.gl-vertical-align-middle = s_('MilestonePage|Milestone ID: %{milestone_id}') % { milestone_id: @milestone.id } = clipboard_button(title: s_('MilestonePage|Copy milestone ID'), text: @milestone.id) - if milestone.try(:description).present? - %div{ data: { qa_selector: "milestone_description_content" } } + %div{ data: { testid: "milestone-description-content" } } .description.md.gl-px-0.gl-pt-4{ class: ('js-task-list-container' if can?(current_user, :admin_milestone, milestone)), data: { lock_version: @milestone.lock_version } } = markdown_field(milestone, :description) -# This textarea is necessary for `task_list.js` to work. diff --git a/app/views/shared/milestones/_form_dates.html.haml b/app/views/shared/milestones/_form_dates.html.haml index 3e75775bf73..22bdcb1f80f 100644 --- a/app/views/shared/milestones/_form_dates.html.haml +++ b/app/views/shared/milestones/_form_dates.html.haml @@ -3,12 +3,12 @@ = f.label :start_date, _('Start Date') %div .issuable-form-select-holder - = f.gitlab_ui_datepicker :start_date, data: { qa_selector: "start_date_field" }, placeholder: _('Select start date'), autocomplete: 'off' + = f.gitlab_ui_datepicker :start_date, data: { testid: "start-date-field" }, placeholder: _('Select start date'), autocomplete: 'off' %a.gl-white-space-nowrap.gl-pl-4.js-clear-start-date{ href: "#" }= _('Clear start date') .gl-form-group %div = f.label :due_date, _('Due Date') %div .issuable-form-select-holder - = f.gitlab_ui_datepicker :due_date, data: { qa_selector: "due_date_field" }, placeholder: _('Select due date'), autocomplete: 'off' + = f.gitlab_ui_datepicker :due_date, data: { testid: "due-date-field" }, placeholder: _('Select due date'), autocomplete: 'off' %a.gl-white-space-nowrap.gl-pl-4.js-clear-due-date{ href: "#" }= _('Clear due date') diff --git a/app/views/shared/milestones/_labels_tab.html.haml b/app/views/shared/milestones/_labels_tab.html.haml index 1e856bf4355..1abf4b46d09 100644 --- a/app/views/shared/milestones/_labels_tab.html.haml +++ b/app/views/shared/milestones/_labels_tab.html.haml @@ -1,3 +1,5 @@ +- add_page_specific_style 'page_bundles/labels' + %ul.bordered-list.manage-labels-list - labels.each do |label| - options = { milestone_title: @milestone.title, label_name: label.title } diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index c36d3a8b92b..6554f557c89 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -6,7 +6,7 @@ .row .col-md-6 .gl-mb-2 - %strong{ data: { qa_selector: "milestone_link", qa_milestone_title: milestone.title } } + %strong{ data: { testid: "milestone-link", qa_milestone_title: milestone.title } } = link_to truncate(milestone.title, length: 100), milestone_path(milestone) - if @group || dashboard = " - #{milestone_type}" diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index 9387d8d3ad1..7d1e9c06966 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -24,7 +24,7 @@ - if @project && can?(current_user, :admin_milestone, @project) = link_to s_('MilestoneSidebar|Edit'), edit_project_milestone_path(@project, @milestone), class: 'js-sidebar-dropdown-toggle edit-link float-right' .value - %span.value-content{ data: { qa_selector: 'start_date_content' } } + %span.value-content{ data: { testid: 'start-date-content' } } - if milestone.start_date %span.bold= milestone.start_date.to_fs(:medium) - else @@ -61,7 +61,7 @@ - if @project && can?(current_user, :admin_milestone, @project) = link_to s_('MilestoneSidebar|Edit'), edit_project_milestone_path(@project, @milestone), class: 'js-sidebar-dropdown-toggle edit-link float-right' .value.hide-collapsed - %span.value-content{ data: { qa_selector: 'due_date_content' } } + %span.value-content{ data: { testid: 'due-date-content' } } - if milestone.due_date %span.bold= milestone.due_date.to_fs(:medium) - else diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml index bbcd072c762..53205301ec7 100644 --- a/app/views/shared/notes/_comment_button.html.haml +++ b/app/views/shared/notes/_comment_button.html.haml @@ -1,5 +1,5 @@ - noteable_name = @note.noteable.human_class_name .js-comment-type-dropdown.float-left.gl-sm-mr-3{ data: { noteable_name: noteable_name } } - = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { class: 'js-comment-button js-comment-submit-button', value: _('Comment'), data: { qa_selector: 'comment_button' }}) do + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { class: 'js-comment-button js-comment-submit-button', value: _('Comment'), data: { testid: 'comment-button' }}) do = _('Comment') diff --git a/app/views/shared/notes/_edit_form.html.haml b/app/views/shared/notes/_edit_form.html.haml index 40a71aa53dc..d4dec49c367 100644 --- a/app/views/shared/notes/_edit_form.html.haml +++ b/app/views/shared/notes/_edit_form.html.haml @@ -4,13 +4,13 @@ = hidden_field_tag :target_type, '', class: 'js-form-target-type' .flash-container = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(project), referenced_users: true } do - = render 'shared/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', qa_selector: 'edit_note_field', placeholder: _("Write a comment or drag your files here…") + = render 'shared/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', testid: 'edit-note-field', placeholder: _("Write a comment or drag your files here…") = render 'shared/notes/hints' .note-form-actions.clearfix .settings-message.note-edit-warning.js-finish-edit-warning = _("Finish editing this message first!") - = render Pajamas::ButtonComponent.new(type: 'submit', variant: :confirm, button_options: { class: 'js-comment-save-button', data: { qa_selector: 'save_comment_button' } }) do + = render Pajamas::ButtonComponent.new(type: 'submit', variant: :confirm, button_options: { class: 'js-comment-save-button', data: { testid: 'save-comment-button' } }) do = _("Save comment") = render Pajamas::ButtonComponent.new(button_options: { class: 'note-edit-cancel' }) do = _("Cancel") diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml index 9a5e9b2179f..d37d707e935 100644 --- a/app/views/shared/notes/_form.html.haml +++ b/app/views/shared/notes/_form.html.haml @@ -26,7 +26,7 @@ .discussion-form-container.discussion-with-resolve-btn.flex-column.p-0 = render layout: 'shared/md_preview', locals: { url: preview_url, referenced_users: true, supports_quick_actions: supports_quick_actions } do - = render 'shared/zen', f: f, qa_selector: 'note_field', + = render 'shared/zen', f: f, testid: 'note-field', attr: :note, classes: 'note-textarea js-note-text', placeholder: _("Write a comment or drag your files here…"), @@ -38,5 +38,5 @@ .note-form-actions.clearfix.gl-display-flex.gl-flex-wrap = render partial: 'shared/notes/comment_button' - %a.btn.gl-button.btn-cancel.js-close-discussion-note-form.hide{ role: "button", data: { cancel_text: _("Cancel") } } + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-close-discussion-note-form hide' }) do = _('Cancel') diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index 95e0beee5e0..3e72a66561b 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -35,7 +35,7 @@ %span.note-header-author-name.bold = note.author.name = user_status(note.author) - %spannote-headline-light{ data: { qa_selector: 'note_author_content' } } + %spannote-headline-light{ data: { testid: 'note-author-content' } } = note.author.to_reference %span.note-headline-ligh.note-headline-meta - if note.system @@ -52,7 +52,7 @@ - else = render 'projects/notes/actions', note: note, note_editable: note_editable .note-body{ class: note_editable ? 'js-task-list-container' : '' } - .note-text.md{ data: { qa_selector: 'note_content' } } + .note-text.md{ data: { testid: 'note-content' } } = markdown_field(note, :note) = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago') .original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } } diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index 0fed59aaff3..336fdedf89b 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -1,7 +1,7 @@ - issuable = @issue || @merge_request - discussion_locked = issuable&.discussion_locked? -%ul#notes-list.notes.main-notes-list.timeline{ data: { 'qa_selector': 'notes_list' } } +%ul#notes-list.notes.main-notes-list.timeline{ data: { 'testid': 'notes-list' } } = render "shared/notes/notes" = render 'shared/notes/edit_form', project: @project diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml index a2c831bfd1c..14785870dc0 100644 --- a/app/views/shared/projects/_list.html.haml +++ b/app/views/shared/projects/_list.html.haml @@ -1,3 +1,4 @@ +- add_page_specific_style 'page_bundles/projects' - projects_limit = 20 unless local_assigns[:projects_limit] - avatar = true unless local_assigns[:avatar] == false - use_creator_avatar = false unless local_assigns[:use_creator_avatar] == true @@ -27,7 +28,7 @@ - explore_groups_button_label = _('Explore groups') - explore_groups_button_link = explore_groups_path -.js-projects-list-holder{ data: { qa_selector: 'projects_list' } } +.js-projects-list-holder{ data: { testid: 'projects-list' } } - if any_projects?(projects) - load_pipeline_status(projects) if pipeline_status - load_max_project_member_accesses(projects) # Prime cache used in shared/projects/project view rendered below diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index ac5e65747d5..2de4a9d7780 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -24,7 +24,7 @@ - else = render Pajamas::AvatarComponent.new(project, size: 48, alt: '', class: 'gl-mr-5') .project-cell{ class: css_class } - .project-details.gl-pr-9.gl-sm-pr-0.gl-w-full.gl-display-flex.gl-flex-direction-column{ data: { qa_selector: 'project_content', qa_project_name: project.name } } + .project-details.gl-pr-9.gl-sm-pr-0.gl-w-full.gl-display-flex.gl-flex-direction-column{ data: { testid: 'project-content', qa_project_name: project.name } } .gl-display-flex.gl-align-items-center.gl-flex-wrap %h2.gl-font-base.gl-line-height-20.gl-my-0.gl-overflow-wrap-anywhere = link_to project_path(project), class: 'text-plain gl-mr-3 js-prefetch-document', title: project.name do @@ -45,7 +45,7 @@ - if !explore_projects_tab? && access&.nonzero? -# haml-lint:disable UnnecessaryStringOutput = ' ' # prevent haml from eating the space between elements - %span.user-access-role.gl-display-block.gl-m-0{ data: { qa_selector: 'user_role_content' } }= Gitlab::Access.human_access(access) + %span.user-access-role.gl-display-block.gl-m-0{ data: { testid: 'user-role-content' } }= localized_project_human_access(access) - if !explore_projects_tab? = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: project, additional_classes: 'gl-ml-3!' diff --git a/app/views/shared/projects/_search_form.html.haml b/app/views/shared/projects/_search_form.html.haml index 2388bf2f0be..de54cc2810b 100644 --- a/app/views/shared/projects/_search_form.html.haml +++ b/app/views/shared/projects/_search_form.html.haml @@ -2,7 +2,7 @@ - admin_view ||= false - top_padding = admin_view ? 'gl-lg-pt-3' : '' -= form_tag filter_projects_path, method: :get, class: "project-filter-form gl-display-flex! gl-flex-wrap gl-w-full gl-gap-3 #{top_padding}", data: { qa_selector: 'project_filter_form_container' }, id: 'project-filter-form' do |f| += form_tag filter_projects_path, method: :get, class: "project-filter-form gl-display-flex! gl-flex-wrap gl-w-full gl-gap-3 #{top_padding}", data: { testid: '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 input-short js-projects-list-filter gl-m-0!", diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml index 6caadeb0ba4..9767f7929d0 100644 --- a/app/views/shared/snippets/_snippet.html.haml +++ b/app/views/shared/snippets/_snippet.html.haml @@ -1,13 +1,13 @@ - link_project = local_assigns.fetch(:link_project, false) - notes_count = @noteable_meta_data[snippet.id].user_notes_count -%li.snippet-row.py-3{ data: { qa_selector: 'snippet_link', qa_snippet_title: snippet.title } } +%li.snippet-row.py-3{ data: { testid: 'snippet-link', qa_snippet_title: snippet.title } } = render Pajamas::AvatarComponent.new(snippet.author, size: 48, alt: "", class: 'gl-display-none gl-sm-display-block gl-float-left gl-mr-3') = link_to gitlab_snippet_path(snippet), class: "title" do = snippet.title - %ul.controls{ data: { qa_selector: 'snippet_file_count_content', qa_snippet_files: snippet.statistics&.file_count } } + %ul.controls{ data: { testid: 'snippet-file-count-content', qa_snippet_files: snippet.statistics&.file_count } } %li = snippet_file_count(snippet) %li @@ -15,7 +15,7 @@ = sprite_icon('comments', css_class: 'gl-vertical-align-text-bottom') = notes_count %li - %span.sr-only{ data: { qa_selector: 'snippet_visibility_content', qa_snippet_visibility: visibility_level_label(snippet.visibility_level) } } + %span.sr-only{ data: { testid: 'snippet-visibility-content', qa_snippet_visibility: visibility_level_label(snippet.visibility_level) } } = visibility_level_label(snippet.visibility_level) = visibility_level_icon(snippet.visibility_level) diff --git a/app/views/shared/tokens/_scopes_form.html.haml b/app/views/shared/tokens/_scopes_form.html.haml index 1c63ce490ed..8e363f6c86a 100644 --- a/app/views/shared/tokens/_scopes_form.html.haml +++ b/app/views/shared/tokens/_scopes_form.html.haml @@ -9,7 +9,7 @@ - help_text = t scope, scope: scope_description(description_prefix) = f.gitlab_ui_checkbox_component :scopes, scope, help_text: help_text, - checkbox_options: { checked: token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}", multiple: true, data: { qa_selector: "#{scope}_checkbox" } }, + checkbox_options: { checked: token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}", multiple: true, data: { testid: "#{scope}-checkbox" } }, checked_value: scope, unchecked_value: nil, - label_options: { data: { qa_selector: "#{scope}_label" } } + label_options: { data: { testid: "#{scope}-label" } } diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index f040ea8e542..7c713e63cd7 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -63,7 +63,7 @@ %li.gl-pb-3 = form.gitlab_ui_checkbox_component :releases_events, integration_webhook_event_human_name(:releases_events), - help_text: s_('Webhooks|A release is created or updated.') + help_text: s_('Webhooks|A release is created, updated, or deleted.') - if Feature.enabled?(:emoji_webhooks, hook.parent) %li.gl-pb-5 - emoji_help_link = link_to s_('Which emoji events trigger webhooks'), help_page_path('user/project/integrations/webhook_events.md', anchor: 'emoji-events') diff --git a/app/views/shared/web_hooks/_hook.html.haml b/app/views/shared/web_hooks/_hook.html.haml index 9b84222e920..a332fd9cce7 100644 --- a/app/views/shared/web_hooks/_hook.html.haml +++ b/app/views/shared/web_hooks/_hook.html.haml @@ -1,7 +1,7 @@ - sslStatus = hook.enable_ssl_verification ? _('enabled') : _('disabled') - sslBadgeText = _('SSL Verification:') + ' ' + sslStatus -%li.label-list-item +%li.gl-border-b.gl-last-of-type-border-b-0 .gl-display-flex.lgl-align-items-center.row.gl-mx-0 .col-md-8.col-lg-7.gl-px-5 .light-header.gl-mb-2 diff --git a/app/views/shared/wikis/_form.html.haml b/app/views/shared/wikis/_form.html.haml index 34bedbd928a..cdf4b50a99d 100644 --- a/app/views/shared/wikis/_form.html.haml +++ b/app/views/shared/wikis/_form.html.haml @@ -1,4 +1,4 @@ -- page_info = { last_commit_sha: @page.last_commit_sha, persisted: @page.persisted?, title: @page.title, content: @page.content || '', format: @page.format.to_s, uploads_path: uploads_path, path: wiki_page_path(@wiki, @page), wiki_path: wiki_path(@wiki), help_path: help_page_path('user/project/wiki/index'), markdown_help_path: help_page_path('user/markdown'), markdown_preview_path: wiki_page_path(@wiki, @page, action: :preview_markdown), create_path: wiki_path(@wiki, action: :create) } +- page_info = { last_commit_sha: @page.last_commit_sha, persisted: @page.persisted?, title: @page.title, content: @page.raw_content || '', format: @page.format.to_s, uploads_path: uploads_path, path: wiki_page_path(@wiki, @page), wiki_path: wiki_path(@wiki), help_path: help_page_path('user/project/wiki/index'), markdown_help_path: help_page_path('user/markdown'), markdown_preview_path: wiki_page_path(@wiki, @page, action: :preview_markdown), create_path: wiki_path(@wiki, action: :create) } .gl-mt-3 = form_errors(@page, truncate: :title) diff --git a/app/views/shared/wikis/_main_links.html.haml b/app/views/shared/wikis/_main_links.html.haml index 41831c95198..9a76069e8f6 100644 --- a/app/views/shared/wikis/_main_links.html.haml +++ b/app/views/shared/wikis/_main_links.html.haml @@ -1,6 +1,6 @@ - if @page&.persisted? - = link_button_to wiki_page_path(@wiki, @page, action: :history), role: "button", data: { qa_selector: 'page_history_button' } do + = link_button_to wiki_page_path(@wiki, @page, action: :history), role: "button", data: { testid: 'page-history-button' } do = s_("Wiki|Page history") - if can?(current_user, :create_wiki, @wiki.container) - = link_button_to wiki_path(@wiki, action: :new), role: "button", data: { qa_selector: 'new_page_button' }, variant: :confirm, category: :secondary do + = link_button_to wiki_path(@wiki, action: :new), role: "button", data: { testid: 'new-page-button' }, variant: :confirm, category: :secondary do = s_("Wiki|New page") diff --git a/app/views/shared/wikis/_pages_wiki_page.html.haml b/app/views/shared/wikis/_pages_wiki_page.html.haml index fb6f58d044d..23931bbbc32 100644 --- a/app/views/shared/wikis/_pages_wiki_page.html.haml +++ b/app/views/shared/wikis/_pages_wiki_page.html.haml @@ -1,5 +1,5 @@ %li - = link_to wiki_page.human_title, wiki_page_path(@wiki, wiki_page), data: { qa_selector: 'wiki_page_link', qa_page_name: wiki_page.slug } + = link_to wiki_page.human_title, wiki_page_path(@wiki, wiki_page), data: { testid: 'wiki-page-link', qa_page_name: wiki_page.slug } %small (#{wiki_page.format}) .float-right - if wiki_page.last_version diff --git a/app/views/shared/wikis/_sidebar.html.haml b/app/views/shared/wikis/_sidebar.html.haml index a34827602ab..cd752d31643 100644 --- a/app/views/shared/wikis/_sidebar.html.haml +++ b/app/views/shared/wikis/_sidebar.html.haml @@ -8,7 +8,7 @@ .gl-display-flex.gl-flex-wrap - git_access_url = wiki_path(@wiki, action: :git_access) - = link_to git_access_url, class: 'gl-mr-5' + (active_nav_link?(path: 'wikis#git_access') ? ' active' : ''), data: { qa_selector: 'clone_repository_link' } do + = link_to git_access_url, class: 'gl-mr-5' + (active_nav_link?(path: 'wikis#git_access') ? ' active' : ''), data: { testid: 'clone-repository-link' } do = sprite_icon('download', css_class: 'gl-mr-2') %span= _("Clone repository") @@ -32,5 +32,5 @@ = render partial: entry.to_partial_path, object: entry, locals: { context: 'sidebar' } .block.w-100 - if @sidebar_limited - = link_button_to wiki_path(@wiki, action: :pages), data: { qa_selector: 'view_all_pages_button' }, block: true do + = link_button_to wiki_path(@wiki, action: :pages), data: { testid: 'view-all-pages-button' }, block: true do = s_("Wiki|View All Pages") diff --git a/app/views/shared/wikis/_sidebar_wiki_page.html.haml b/app/views/shared/wikis/_sidebar_wiki_page.html.haml index 2c5c3aa68a3..710ecf6196e 100644 --- a/app/views/shared/wikis/_sidebar_wiki_page.html.haml +++ b/app/views/shared/wikis/_sidebar_wiki_page.html.haml @@ -3,5 +3,5 @@ %li{ class: active_when(params[:id] == wiki_page.slug) } .gl-relative.gl-display-flex.gl-align-items-center.js-wiki-list-toggle.wiki-list{ data: { testid: 'wiki-list' } } = render Pajamas::ButtonComponent.new(icon: 'plus', href: "#{wiki_path}/{new_page_title}", button_options: { class: 'wiki-list-create-child-button gl-bg-transparent! gl-hover-bg-gray-50! gl-focus-bg-gray-50! gl-absolute gl-top-half gl-translate-y-n50 gl-cursor-pointer gl-right-3' }) - = link_to wiki_path, data: { qa_selector: 'wiki_page_link', qa_page_name: wiki_page.human_title } do + = link_to wiki_path, data: { testid: 'wiki-page-link', qa_page_name: wiki_page.human_title } do = wiki_page.human_title diff --git a/app/views/shared/wikis/_wiki_content.html.haml b/app/views/shared/wikis/_wiki_content.html.haml index 780e4c4746d..b5210b340f3 100644 --- a/app/views/shared/wikis/_wiki_content.html.haml +++ b/app/views/shared/wikis/_wiki_content.html.haml @@ -1,2 +1,2 @@ -.js-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki-page-content', tracking_context: wiki_page_tracking_context(@page).to_json } } +.js-wiki-page-content.md.gl-pt-2{ data: { testid: 'wiki-page-content', tracking_context: wiki_page_tracking_context(@page).to_json } } = render_wiki_content(@page) diff --git a/app/views/shared/wikis/_wiki_directory.html.haml b/app/views/shared/wikis/_wiki_directory.html.haml index 6a066e0a838..cce81257691 100644 --- a/app/views/shared/wikis/_wiki_directory.html.haml +++ b/app/views/shared/wikis/_wiki_directory.html.haml @@ -1,11 +1,11 @@ - wiki_path = wiki_page_path(@wiki, wiki_directory) -%li{ class: active_when(params[:id] == wiki_directory.slug), data: { qa_selector: 'wiki_directory_content' } } +%li{ class: active_when(params[:id] == wiki_directory.slug), data: { testid: 'wiki-directory-content' } } .gl-relative.gl-display-flex.gl-align-items-center.js-wiki-list-toggle.wiki-list{ data: { testid: 'wiki-list' } }< = sprite_icon('chevron-right', css_class: 'js-wiki-list-expand-button wiki-list-expand-button gl-mr-2 gl-cursor-pointer') = sprite_icon('chevron-down', css_class: 'js-wiki-list-collapse-button wiki-list-collapse-button gl-mr-2 gl-cursor-pointer') = render Pajamas::ButtonComponent.new(icon: 'plus', href: "#{wiki_path}/{new_page_title}", button_options: { class: 'wiki-list-create-child-button gl-bg-transparent! gl-hover-bg-gray-50! gl-focus-bg-gray-50! gl-absolute gl-top-half gl-translate-y-n50 gl-cursor-pointer gl-right-3' }) - = link_to wiki_path, data: { qa_selector: 'wiki_dir_page_link', qa_page_name: wiki_directory.title } do + = link_to wiki_path, data: { testid: 'wiki-dir-page-link', qa_page_name: wiki_directory.title } do = wiki_directory.title %ul - wiki_directory.entries.each do |entry| diff --git a/app/views/shared/wikis/show.html.haml b/app/views/shared/wikis/show.html.haml index be1f43f44de..9537d6fec15 100644 --- a/app/views/shared/wikis/show.html.haml +++ b/app/views/shared/wikis/show.html.haml @@ -29,10 +29,10 @@ .gl-mt-5.gl-mb-3 .gl-display-flex.gl-justify-content-space-between - %h2.gl-mt-0.gl-mb-5{ data: { qa_selector: 'wiki_page_title', testid: 'wiki_page_title' } }= @page.human_title + %h2.gl-mt-0.gl-mb-5{ data: { testid: 'wiki-page-title' } }= @page.human_title %div - if can?(current_user, :create_wiki, @wiki.container) && @page.latest? && @valid_encoding - = render Pajamas::ButtonComponent.new(href: wiki_page_path(@wiki, @page, action: :edit), icon: 'pencil', button_options: { class: 'js-wiki-edit', title: "Edit", data: { qa_selector: 'edit_page_button', testid: 'wiki_edit_button' }}) + = render Pajamas::ButtonComponent.new(href: wiki_page_path(@wiki, @page, action: :edit), icon: 'pencil', button_options: { class: 'js-wiki-edit', title: "Edit", data: { testid: 'wiki-edit-button' }}) .js-async-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki-page-content', tracking_context: wiki_page_tracking_context(@page).to_json, get_wiki_content_url: wiki_page_render_api_endpoint(@page) } } diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml index e3a14b0454e..07640f579a2 100644 --- a/app/views/snippets/notes/_actions.html.haml +++ b/app/views/snippets/notes/_actions.html.haml @@ -8,6 +8,6 @@ - if note_editable .note-actions-item.gl-ml-0 - = render Pajamas::ButtonComponent.new(category: :tertiary, icon: 'pencil', button_options: { title: _('Edit comment'), class: 'note-action-button js-note-edit has-tooltip', data: { container: 'body', qa_selector: 'edit_comment_button' } }) + = render Pajamas::ButtonComponent.new(category: :tertiary, icon: 'pencil', button_options: { title: _('Edit comment'), class: 'note-action-button js-note-edit has-tooltip', data: { container: 'body', testid: 'edit-comment-button' } }) = render 'projects/notes/more_actions_dropdown', note: note, note_editable: note_editable diff --git a/app/views/users/_cover_controls.html.haml b/app/views/users/_cover_controls.html.haml index 43278e9d232..899a08c8a17 100644 --- a/app/views/users/_cover_controls.html.haml +++ b/app/views/users/_cover_controls.html.haml @@ -1,2 +1,2 @@ -.cover-controls.d-flex.px-2.pb-4.d-sm-block.p-sm-0 +.cover-controls.gl-display-flex.gl-gap-3.gl-pb-4 = yield diff --git a/app/views/users/_follow_user.html.haml b/app/views/users/_follow_user.html.haml index 3ee8c81496c..71f8a462cbf 100644 --- a/app/views/users/_follow_user.html.haml +++ b/app/views/users/_follow_user.html.haml @@ -7,5 +7,5 @@ = _('Unfollow') - else = form_tag user_follow_path(@user, :json), class: link_classes do - = render Pajamas::ButtonComponent.new(variant: :confirm, type: :submit, button_options: { class: 'gl-w-full', data: { qa_selector: 'follow_user_link', track_action: 'click_button', track_label: 'follow_from_profile' } }) do + = render Pajamas::ButtonComponent.new(variant: :confirm, type: :submit, button_options: { class: 'gl-w-full', data: { testid: 'follow-user-link', track_action: 'click_button', track_label: 'follow_from_profile' } }) do = _('Follow') diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml index 0b76ed6c086..3649f72c956 100644 --- a/app/views/users/_overview.html.haml +++ b/app/views/users/_overview.html.haml @@ -33,7 +33,7 @@ %h4.gl-flex-grow-1 = Feature.enabled?(:security_auto_fix) && @user.bot? ? s_('UserProfile|Bot activity') : s_('UserProfile|Activity') = link_to s_('UserProfile|View all'), user_activity_path, class: "hide js-view-all" - .overview-content-list{ data: { href: user_activity_path, qa_selector: 'user_activity_content' } } + .overview-content-list{ data: { href: user_activity_path, testid: 'user-activity-content' } } = gl_loading_icon(size: 'md', css_class: 'loading') - unless Feature.enabled?(:security_auto_fix) && @user.bot? diff --git a/app/views/users/_profile_basic_info.html.haml b/app/views/users/_profile_basic_info.html.haml index fb9721028d5..6de9e80008e 100644 --- a/app/views/users/_profile_basic_info.html.haml +++ b/app/views/users/_profile_basic_info.html.haml @@ -5,6 +5,6 @@ - unless Feature.enabled?(:user_profile_overflow_menu_vue) = render 'middle_dot_divider', stacking: true do = s_('UserProfile|User ID: %{id}') % { id: @user.id } - = deprecated_clipboard_button(title: s_('UserProfile|Copy user ID'), text: @user.id) + = clipboard_button(title: s_('UserProfile|Copy user ID'), text: @user.id) = render 'middle_dot_divider', stacking: true do = s_('Member since %{date}') % { date: l(@user.created_at.to_date, format: :long) } diff --git a/app/views/users/_view_user_in_admin_area.html.haml b/app/views/users/_view_user_in_admin_area.html.haml index b13f22956f6..36b3c33d8ab 100644 --- a/app/views/users/_view_user_in_admin_area.html.haml +++ b/app/views/users/_view_user_in_admin_area.html.haml @@ -1,4 +1,4 @@ - if current_user && current_user.admin? = render Pajamas::ButtonComponent.new(href: [:admin, @user], icon: 'user', - button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|View user in admin area'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } }) + button_options: { class: 'gl-flex-grow-1 has-tooltip', title: s_('UserProfile|View user in admin area'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } }) diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index a2f6b3da746..0881c5bba54 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -5,6 +5,7 @@ - page_description @user.bio unless @user.blocked? || !@user.confirmed? - page_itemtype 'http://schema.org/Person' - add_page_specific_style 'page_bundles/profile' +- add_page_specific_style 'page_bundles/projects' - if show_super_sidebar? - @left_sidebar = true - @force_desktop_expanded_sidebar = true @@ -17,7 +18,7 @@ .cover-block.user-cover-block.gl-border-t.gl-border-b.gl-mt-n1 %div{ class: container_class } - if Feature.enabled?(:user_profile_overflow_menu_vue) - .cover-controls.d-flex.px-2.pb-4.d-sm-block.p-sm-0 + .cover-controls.gl-display-flex.gl-gap-3.gl-pb-4 = render 'users/follow_user' -# The following edit button is mutually exclusive to the follow user button, they won't be shown together - if @user == current_user @@ -32,14 +33,14 @@ - if @user == current_user = render Pajamas::ButtonComponent.new(href: profile_path, icon: 'pencil', - button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Edit profile'), 'aria-label': 'Edit profile', data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }}) + button_options: { class: 'gl-flex-grow-1 has-tooltip', title: s_('UserProfile|Edit profile'), 'aria-label': 'Edit profile', data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }}) - elsif current_user #js-report-abuse{ data: { report_abuse_path: add_category_abuse_reports_path, reported_user_id: @user.id, reported_from_url: user_url(@user) } } = render 'users/view_gpg_keys' - if can?(current_user, :read_user_profile, @user) = render Pajamas::ButtonComponent.new(href: user_path(@user, rss_url_options), icon: 'rss', - button_options: { class: 'gl-flex-grow-1 gl-mx-1 has-tooltip', title: s_('UserProfile|Subscribe'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }}) + button_options: { class: 'gl-flex-grow-1 has-tooltip', title: s_('UserProfile|Subscribe'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }}) = render 'users/view_user_in_admin_area' = render 'users/follow_user' diff --git a/app/views/users/terms/index.html.haml b/app/views/users/terms/index.html.haml index afe257c2fc2..9f7c17dad9a 100644 --- a/app/views/users/terms/index.html.haml +++ b/app/views/users/terms/index.html.haml @@ -1,7 +1,7 @@ - content_for :page_specific_javascripts do - = render "layouts/google_tag_manager_head" + = render_if_exists "layouts/google_tag_manager_head" = render "layouts/one_trust" = render "layouts/bizible" -= render "layouts/google_tag_manager_body" += render_if_exists "layouts/google_tag_manager_body" #js-terms-of-service{ data: { terms_data: terms_data(@term, @redirect) } } diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 6ef7447b9da..e5b860ba525 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -174,15 +174,6 @@ :weight: 1 :idempotent: true :tags: [] -- :name: container_repository:delete_container_repository - :worker_name: DeleteContainerRepositoryWorker - :feature_category: :container_registry - :has_external_dependencies: false - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: false - :tags: [] - :name: container_repository_delete:container_registry_delete_container_repository :worker_name: ContainerRegistry::DeleteContainerRepositoryWorker :feature_category: :container_registry @@ -300,6 +291,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: cronjob:ci_schedule_unlock_pipelines_in_queue_cron + :worker_name: Ci::ScheduleUnlockPipelinesInQueueCronWorker + :feature_category: :build_artifacts + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:ci_stuck_builds_drop_running :worker_name: Ci::StuckBuilds::DropRunningWorker :feature_category: :continuous_integration @@ -327,6 +327,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: cronjob:click_house_events_sync + :worker_name: ClickHouse::EventsSyncWorker + :feature_category: :value_stream_management + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:container_expiration_policy :worker_name: ContainerExpirationPolicyWorker :feature_category: :container_registry @@ -642,6 +651,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: cronjob:pages_deactivated_deployments_delete_cron + :worker_name: Pages::DeactivatedDeploymentsDeleteCronWorker + :feature_category: :pages + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:pages_domain_removal_cron :worker_name: PagesDomainRemovalCronWorker :feature_category: :pages @@ -1920,6 +1938,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: pipeline_background:ci_refs_unlock_previous_pipelines + :worker_name: Ci::Refs::UnlockPreviousPipelinesWorker + :feature_category: :continuous_integration + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: pipeline_background:ci_test_failure_history :worker_name: Ci::TestFailureHistoryWorker :feature_category: :continuous_integration @@ -2352,6 +2379,33 @@ :weight: 1 :idempotent: false :tags: [] +- :name: bitbucket_import_import_issue + :worker_name: Gitlab::BitbucketImport::ImportIssueWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] +- :name: bitbucket_import_import_issue_notes + :worker_name: Gitlab::BitbucketImport::ImportIssueNotesWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] +- :name: bitbucket_import_import_lfs_object + :worker_name: Gitlab::BitbucketImport::ImportLfsObjectWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] - :name: bitbucket_import_import_pull_request :worker_name: Gitlab::BitbucketImport::ImportPullRequestWorker :feature_category: :importers @@ -2361,6 +2415,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: bitbucket_import_import_pull_request_notes + :worker_name: Gitlab::BitbucketImport::ImportPullRequestNotesWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] - :name: bitbucket_import_stage_finish_import :worker_name: Gitlab::BitbucketImport::Stage::FinishImportWorker :feature_category: :importers @@ -2370,6 +2433,33 @@ :weight: 1 :idempotent: false :tags: [] +- :name: bitbucket_import_stage_import_issues + :worker_name: Gitlab::BitbucketImport::Stage::ImportIssuesWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] +- :name: bitbucket_import_stage_import_issues_notes + :worker_name: Gitlab::BitbucketImport::Stage::ImportIssuesNotesWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] +- :name: bitbucket_import_stage_import_lfs_objects + :worker_name: Gitlab::BitbucketImport::Stage::ImportLfsObjectsWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] - :name: bitbucket_import_stage_import_pull_requests :worker_name: Gitlab::BitbucketImport::Stage::ImportPullRequestsWorker :feature_category: :importers @@ -2379,6 +2469,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: bitbucket_import_stage_import_pull_requests_notes + :worker_name: Gitlab::BitbucketImport::Stage::ImportPullRequestsNotesWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] - :name: bitbucket_import_stage_import_repository :worker_name: Gitlab::BitbucketImport::Stage::ImportRepositoryWorker :feature_category: :importers @@ -2631,10 +2730,10 @@ :weight: 1 :idempotent: true :tags: [] -- :name: click_house_events_sync - :worker_name: ClickHouse::EventsSyncWorker - :feature_category: :value_stream_management - :has_external_dependencies: true +- :name: ci_unlock_pipelines_in_queue + :worker_name: Ci::UnlockPipelinesInQueueWorker + :feature_category: :build_artifacts + :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -2811,6 +2910,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: environments_stop_job_failed + :worker_name: Environments::StopJobFailedWorker + :feature_category: :continuous_delivery + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: environments_stop_job_success :worker_name: Environments::StopJobSuccessWorker :feature_category: :continuous_delivery @@ -2883,15 +2991,6 @@ :weight: 1 :idempotent: true :tags: [] -- :name: gitlab_shell - :worker_name: GitlabShellWorker - :feature_category: :source_code_management - :has_external_dependencies: false - :urgency: :high - :resource_boundary: :unknown - :weight: 2 - :idempotent: false - :tags: [] - :name: google_cloud_create_cloudsql_instance :worker_name: GoogleCloud::CreateCloudsqlInstanceWorker :feature_category: :not_owned @@ -3045,6 +3144,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: issuable_related_links_create + :worker_name: Issuable::RelatedLinksCreateWorker + :feature_category: :portfolio_management + :has_external_dependencies: false + :urgency: :high + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: issuables_clear_groups_issue_counter :worker_name: Issuables::ClearGroupsIssueCounterWorker :feature_category: :team_planning @@ -3524,7 +3632,7 @@ :tags: [] - :name: projects_record_target_platforms :worker_name: Projects::RecordTargetPlatformsWorker - :feature_category: :experimentation_activation + :feature_category: :activation :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown diff --git a/app/workers/bulk_import_worker.rb b/app/workers/bulk_import_worker.rb index 83b881ee525..5b9b46081cc 100644 --- a/app/workers/bulk_import_worker.rb +++ b/app/workers/bulk_import_worker.rb @@ -3,124 +3,14 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker - PERFORM_DELAY = 5.seconds - DEFAULT_BATCH_SIZE = 5 - data_consistency :always feature_category :importers sidekiq_options retry: false, dead: false def perform(bulk_import_id) - @bulk_import = BulkImport.find_by_id(bulk_import_id) - - return unless @bulk_import - return if @bulk_import.finished? || @bulk_import.failed? - return @bulk_import.fail_op! if all_entities_failed? - return @bulk_import.finish! if all_entities_processed? && @bulk_import.started? - return re_enqueue if max_batch_size_exceeded? # Do not start more jobs if max allowed are already running - - @bulk_import.start! if @bulk_import.created? - - created_entities.first(next_batch_size).each do |entity| - create_tracker(entity) - - entity.start! - - BulkImports::ExportRequestWorker.perform_async(entity.id) - end - - re_enqueue - rescue StandardError => e - Gitlab::ErrorTracking.track_exception(e, bulk_import_id: @bulk_import&.id) - - @bulk_import&.fail_op - end - - private - - def entities - @entities ||= @bulk_import.entities - end - - def created_entities - entities.with_status(:created) - end - - def all_entities_processed? - entities.all? { |entity| entity.finished? || entity.failed? } - end - - def all_entities_failed? - entities.all?(&:failed?) - end - - # A new BulkImportWorker job is enqueued to either - # - Process the new BulkImports::Entity created during import (e.g. for the subgroups) - # - Or to mark the `bulk_import` as finished - def re_enqueue - BulkImportWorker.perform_in(PERFORM_DELAY, @bulk_import.id) - end - - def started_entities - entities.with_status(:started) - end - - def max_batch_size_exceeded? - started_entities.count >= DEFAULT_BATCH_SIZE - end - - def next_batch_size - [DEFAULT_BATCH_SIZE - started_entities.count, 0].max - end - - def create_tracker(entity) - entity.class.transaction do - entity.pipelines.each do |pipeline| - status = skip_pipeline?(pipeline, entity) ? :skipped : :created - - entity.trackers.create!( - stage: pipeline[:stage], - pipeline_name: pipeline[:pipeline], - status: BulkImports::Tracker.state_machine.states[status].value - ) - end - end - end - - def skip_pipeline?(pipeline, entity) - return false unless entity.source_version.valid? - - minimum_version, maximum_version = pipeline.values_at(:minimum_source_version, :maximum_source_version) - - if source_version_out_of_range?(minimum_version, maximum_version, entity.source_version.without_patch) - log_skipped_pipeline(pipeline, entity, minimum_version, maximum_version) - return true - end - - false - end - - def source_version_out_of_range?(minimum_version, maximum_version, non_patch_source_version) - (minimum_version && non_patch_source_version < Gitlab::VersionInfo.parse(minimum_version)) || - (maximum_version && non_patch_source_version > Gitlab::VersionInfo.parse(maximum_version)) - end - - def log_skipped_pipeline(pipeline, entity, minimum_version, maximum_version) - logger.info( - message: 'Pipeline skipped as source instance version not compatible with pipeline', - 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[:pipeline], - minimum_source_version: minimum_version, - maximum_source_version: maximum_version, - source_version: entity.source_version.to_s, - importer: 'gitlab_migration' - ) - end + bulk_import = BulkImport.find_by_id(bulk_import_id) + return unless bulk_import - def logger - @logger ||= Gitlab::Import::Logger.build + BulkImports::ProcessService.new(bulk_import).execute end end diff --git a/app/workers/bulk_imports/entity_worker.rb b/app/workers/bulk_imports/entity_worker.rb index fb99d63d06e..9b60dcdeb8a 100644 --- a/app/workers/bulk_imports/entity_worker.rb +++ b/app/workers/bulk_imports/entity_worker.rb @@ -1,97 +1,68 @@ # frozen_string_literal: true module BulkImports - class EntityWorker # rubocop:disable Scalability/IdempotentWorker + class EntityWorker include ApplicationWorker idempotent! - deduplicate :until_executing + deduplicate :until_executed data_consistency :always feature_category :importers sidekiq_options retry: false, dead: false worker_has_external_dependencies! - def perform(entity_id, current_stage = nil) + PERFORM_DELAY = 5.seconds + + # Keep `_current_stage` parameter for backwards compatibility. + # The parameter will be remove in https://gitlab.com/gitlab-org/gitlab/-/issues/426311 + def perform(entity_id, _current_stage = nil) @entity = ::BulkImports::Entity.find(entity_id) - if stage_running?(entity_id, current_stage) - 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, - current_stage: current_stage, - message: 'Stage running', - source_version: source_version, - importer: 'gitlab_migration' - ) - ) + return unless @entity.started? - return + if running_tracker.present? + log_info(message: 'Stage running', entity_stage: running_tracker.stage) + else + start_next_stage end - 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, - current_stage: current_stage, - message: 'Stage starting', - source_version: source_version, - importer: 'gitlab_migration' - ) - ) - - next_pipeline_trackers_for(entity_id).each do |pipeline_tracker| - BulkImports::PipelineWorker.perform_async( - pipeline_tracker.id, - pipeline_tracker.stage, - entity_id - ) - end + re_enqueue rescue StandardError => e - 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, - current_stage: current_stage, - message: 'Entity failed', - source_version: source_version, - importer: 'gitlab_migration' - } - ) - - Gitlab::ErrorTracking.track_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, - source_version: source_version, - importer: 'gitlab_migration' - ) + Gitlab::ErrorTracking.track_exception(e, log_params(message: 'Entity failed')) - entity.fail_op! + @entity.fail_op! end private attr_reader :entity - def stage_running?(entity_id, stage) - return unless stage + def re_enqueue + BulkImports::EntityWorker.perform_in(PERFORM_DELAY, entity.id) + end - BulkImports::Tracker.stage_running?(entity_id, stage) + def running_tracker + @running_tracker ||= BulkImports::Tracker.running_trackers(entity.id).first end def next_pipeline_trackers_for(entity_id) BulkImports::Tracker.next_pipeline_trackers_for(entity_id).update(status_event: 'enqueue') end + def start_next_stage + next_pipeline_trackers = next_pipeline_trackers_for(entity.id) + + next_pipeline_trackers.each_with_index do |pipeline_tracker, index| + log_info(message: 'Stage starting', entity_stage: pipeline_tracker.stage) if index == 0 + + BulkImports::PipelineWorker.perform_async( + pipeline_tracker.id, + pipeline_tracker.stage, + entity.id + ) + end + end + def source_version entity.bulk_import.source_version_info.to_s end @@ -105,5 +76,22 @@ module BulkImports logger.error(structured_payload(payload)) end + + def log_info(payload) + logger.info(structured_payload(log_params(payload))) + end + + def log_params(extra) + defaults = { + 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, + source_version: source_version, + importer: 'gitlab_migration' + } + + defaults.merge(extra) + end end end diff --git a/app/workers/bulk_imports/finish_batched_pipeline_worker.rb b/app/workers/bulk_imports/finish_batched_pipeline_worker.rb index 4200d0e4a0f..b1f3757e058 100644 --- a/app/workers/bulk_imports/finish_batched_pipeline_worker.rb +++ b/app/workers/bulk_imports/finish_batched_pipeline_worker.rb @@ -12,6 +12,8 @@ module BulkImports data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency feature_category :importers + version 2 + def perform(pipeline_tracker_id) @tracker = Tracker.find(pipeline_tracker_id) @@ -27,7 +29,9 @@ module BulkImports end ensure - ::BulkImports::EntityWorker.perform_async(tracker.entity.id, tracker.stage) + # This is needed for in-flight migrations. + # It will be remove in https://gitlab.com/gitlab-org/gitlab/-/issues/426299 + ::BulkImports::EntityWorker.perform_async(tracker.entity.id) if job_version.nil? end private @@ -39,7 +43,7 @@ module BulkImports end def import_in_progress? - tracker.batches.any?(&:started?) + tracker.batches.any? { |b| b.started? || b.created? } end end end diff --git a/app/workers/bulk_imports/pipeline_batch_worker.rb b/app/workers/bulk_imports/pipeline_batch_worker.rb index 634d7ed3c87..6230d517641 100644 --- a/app/workers/bulk_imports/pipeline_batch_worker.rb +++ b/app/workers/bulk_imports/pipeline_batch_worker.rb @@ -14,15 +14,16 @@ module BulkImports def perform(batch_id) @batch = ::BulkImports::BatchTracker.find(batch_id) @tracker = @batch.tracker + @pending_retry = false try_obtain_lease { run } ensure - ::BulkImports::FinishBatchedPipelineWorker.perform_async(tracker.id) + ::BulkImports::FinishBatchedPipelineWorker.perform_async(tracker.id) unless pending_retry end private - attr_reader :batch, :tracker + attr_reader :batch, :tracker, :pending_retry def run return batch.skip! if tracker.failed? || tracker.finished? @@ -31,6 +32,7 @@ module BulkImports tracker.pipeline_class.new(context).run batch.finish! rescue BulkImports::RetryPipelineError => e + @pending_retry = true retry_batch(e) rescue StandardError => e fail_batch(e) diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb index 098e167ac29..24185f43795 100644 --- a/app/workers/bulk_imports/pipeline_worker.rb +++ b/app/workers/bulk_imports/pipeline_worker.rb @@ -14,7 +14,10 @@ module BulkImports deduplicate :until_executing worker_resource_boundary :memory - def perform(pipeline_tracker_id, stage, entity_id) + version 2 + + # Keep _stage parameter for backwards compatibility. + def perform(pipeline_tracker_id, _stage, entity_id) @entity = ::BulkImports::Entity.find(entity_id) @pipeline_tracker = ::BulkImports::Tracker.find(pipeline_tracker_id) @@ -32,7 +35,9 @@ module BulkImports end end ensure - ::BulkImports::EntityWorker.perform_async(entity_id, stage) + # This is needed for in-flight migrations. + # It will be remove in https://gitlab.com/gitlab-org/gitlab/-/issues/426299 + ::BulkImports::EntityWorker.perform_async(entity_id) if job_version.nil? end private diff --git a/app/workers/ci/initial_pipeline_process_worker.rb b/app/workers/ci/initial_pipeline_process_worker.rb index 067dbb7492f..703cae8bf88 100644 --- a/app/workers/ci/initial_pipeline_process_worker.rb +++ b/app/workers/ci/initial_pipeline_process_worker.rb @@ -28,6 +28,8 @@ module Ci private def create_deployments!(pipeline) + return if Feature.enabled?(:create_deployment_only_for_processable_jobs, pipeline.project) + pipeline.stages.flat_map(&:statuses).each { |build| create_deployment(build) } end diff --git a/app/workers/ci/merge_requests/add_todo_when_build_fails_worker.rb b/app/workers/ci/merge_requests/add_todo_when_build_fails_worker.rb index 98bb259db0a..8bcbe9d6c9f 100644 --- a/app/workers/ci/merge_requests/add_todo_when_build_fails_worker.rb +++ b/app/workers/ci/merge_requests/add_todo_when_build_fails_worker.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + module Ci module MergeRequests class AddTodoWhenBuildFailsWorker diff --git a/app/workers/ci/ref_delete_unlock_artifacts_worker.rb b/app/workers/ci/ref_delete_unlock_artifacts_worker.rb index aeadf111bfb..e343c0aedd4 100644 --- a/app/workers/ci/ref_delete_unlock_artifacts_worker.rb +++ b/app/workers/ci/ref_delete_unlock_artifacts_worker.rb @@ -13,17 +13,21 @@ module Ci def perform(project_id, user_id, ref_path) ::Project.find_by_id(project_id).try do |project| - ::User.find_by_id(user_id).try do |user| + ::User.find_by_id(user_id).try do |_| project.ci_refs.find_by_ref_path(ref_path).try do |ci_ref| - results = ::Ci::UnlockArtifactsService - .new(project, user) - .execute(ci_ref) - - log_extra_metadata_on_done(:unlocked_pipelines, results[:unlocked_pipelines]) - log_extra_metadata_on_done(:unlocked_job_artifacts, results[:unlocked_job_artifacts]) + enqueue_pipelines_to_unlock(ci_ref) end end end end + + private + + def enqueue_pipelines_to_unlock(ci_ref) + result = ::Ci::Refs::EnqueuePipelinesToUnlockService.new.execute(ci_ref) + + log_extra_metadata_on_done(:total_pending_entries, result[:total_pending_entries]) + log_extra_metadata_on_done(:total_new_entries, result[:total_new_entries]) + end end end diff --git a/app/workers/ci/refs/unlock_previous_pipelines_worker.rb b/app/workers/ci/refs/unlock_previous_pipelines_worker.rb new file mode 100644 index 00000000000..bf595590cb1 --- /dev/null +++ b/app/workers/ci/refs/unlock_previous_pipelines_worker.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Ci + module Refs + class UnlockPreviousPipelinesWorker + include ApplicationWorker + + data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency + + sidekiq_options retry: 3 + include PipelineBackgroundQueue + + idempotent! + + def perform(ref_id) + ::Ci::Ref.find_by_id(ref_id).try do |ref| + pipeline = ref.last_finished_pipeline + result = ::Ci::Refs::EnqueuePipelinesToUnlockService.new.execute(ref, before_pipeline: pipeline) + + log_extra_metadata_on_done(:total_pending_entries, result[:total_pending_entries]) + log_extra_metadata_on_done(:total_new_entries, result[:total_new_entries]) + end + end + end + end +end diff --git a/app/workers/ci/schedule_unlock_pipelines_in_queue_cron_worker.rb b/app/workers/ci/schedule_unlock_pipelines_in_queue_cron_worker.rb new file mode 100644 index 00000000000..1a593326120 --- /dev/null +++ b/app/workers/ci/schedule_unlock_pipelines_in_queue_cron_worker.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Ci + class ScheduleUnlockPipelinesInQueueCronWorker + include ApplicationWorker + + data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency + + # rubocop:disable Scalability/CronWorkerContext + # This worker does not perform work scoped to a context + include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext + + feature_category :build_artifacts + idempotent! + + def perform(...) + Ci::UnlockPipelinesInQueueWorker.perform_with_capacity(...) + end + end +end diff --git a/app/workers/ci/unlock_pipelines_in_queue_worker.rb b/app/workers/ci/unlock_pipelines_in_queue_worker.rb new file mode 100644 index 00000000000..de579504711 --- /dev/null +++ b/app/workers/ci/unlock_pipelines_in_queue_worker.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Ci + class UnlockPipelinesInQueueWorker + include ApplicationWorker + + data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency + + include LimitedCapacity::Worker + + feature_category :build_artifacts + idempotent! + + MAX_RUNNING_LOW = 50 + MAX_RUNNING_MEDIUM = 500 + MAX_RUNNING_HIGH = 1500 + + def perform_work(*_) + pipeline_id, enqueue_timestamp = Ci::UnlockPipelineRequest.next! + return log_extra_metadata_on_done(:remaining_pending, 0) unless pipeline_id + + Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| + log_extra_metadata_on_done(:pipeline_id, pipeline.id) + log_extra_metadata_on_done(:project, pipeline.project.full_path) + + result = Ci::UnlockPipelineService.new(pipeline).execute + + log_extra_metadata_on_done(:unlock_wait_time, Time.current.utc.to_i - enqueue_timestamp) + log_extra_metadata_on_done(:remaining_pending, Ci::UnlockPipelineRequest.total_pending) + log_extra_metadata_on_done(:skipped_already_leased, result[:skipped_already_leased]) + log_extra_metadata_on_done(:skipped_already_unlocked, result[:skipped_already_unlocked]) + log_extra_metadata_on_done(:exec_timeout, result[:exec_timeout]) + log_extra_metadata_on_done(:unlocked_job_artifacts, result[:unlocked_job_artifacts]) + log_extra_metadata_on_done(:unlocked_pipeline_artifacts, result[:unlocked_pipeline_artifacts]) + end + end + + def remaining_work_count(*_) + Ci::UnlockPipelineRequest.total_pending + end + + def max_running_jobs + if ::Feature.enabled?(:ci_unlock_pipelines_high, type: :ops) + MAX_RUNNING_HIGH + elsif ::Feature.enabled?(:ci_unlock_pipelines_medium, type: :ops) + MAX_RUNNING_MEDIUM + elsif ::Feature.enabled?(:ci_unlock_pipelines, type: :ops) + # This is the default enabled flag + MAX_RUNNING_LOW + else + 0 + end + end + end +end diff --git a/app/workers/click_house/events_sync_worker.rb b/app/workers/click_house/events_sync_worker.rb index 5b7398cb071..e884a43b1e3 100644 --- a/app/workers/click_house/events_sync_worker.rb +++ b/app/workers/click_house/events_sync_worker.rb @@ -6,6 +6,7 @@ module ClickHouse include Gitlab::ExclusiveLeaseHelpers idempotent! + queue_namespace :cronjob data_consistency :delayed worker_has_external_dependencies! # the worker interacts with a ClickHouse database feature_category :value_stream_management diff --git a/app/workers/concerns/auto_devops_queue.rb b/app/workers/concerns/auto_devops_queue.rb index 61e3c1544bd..cdf429a8be5 100644 --- a/app/workers/concerns/auto_devops_queue.rb +++ b/app/workers/concerns/auto_devops_queue.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -# + module AutoDevopsQueue extend ActiveSupport::Concern diff --git a/app/workers/concerns/chaos_queue.rb b/app/workers/concerns/chaos_queue.rb index 23e58b5182b..9a3d518dda8 100644 --- a/app/workers/concerns/chaos_queue.rb +++ b/app/workers/concerns/chaos_queue.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -# + module ChaosQueue extend ActiveSupport::Concern diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb index e190ced5073..fcc7a96fa2b 100644 --- a/app/workers/concerns/gitlab/github_import/object_importer.rb +++ b/app/workers/concerns/gitlab/github_import/object_importer.rb @@ -10,7 +10,6 @@ module Gitlab included do include ApplicationWorker - sidekiq_options retry: 3 include GithubImport::Queue include ReschedulingMethods @@ -19,11 +18,8 @@ module Gitlab sidekiq_retries_exhausted do |msg| args = msg['args'] - correlation_id = msg['correlation_id'] jid = msg['jid'] - new.perform_failure(args[0], args[1], correlation_id) - # If a job is being exhausted we still want to notify the # Gitlab::Import::AdvanceStageWorker to prevent the entire import from getting stuck if args.length == 3 && (key = args.last) && key.is_a?(String) @@ -64,29 +60,15 @@ module Gitlab rescue NoMethodError => e # This exception will be more useful in development when a new # Representation is created but the developer forgot to add a - # `:github_identifiers` field. + # `#github_identifiers` method. track_and_raise_exception(project, e, fail_import: true) rescue ActiveRecord::RecordInvalid, NotRetriableError => e # We do not raise exception to prevent job retry - failure = track_exception(project, e) - add_identifiers_to_failure(failure, object.github_identifiers) + track_exception(project, e) rescue StandardError => e track_and_raise_exception(project, e) end - # hash - A Hash containing the details of the object to import. - def perform_failure(project_id, hash, correlation_id) - project = Project.find_by_id(project_id) - return unless project - - failure = project.import_failures.failures_by_correlation_id(correlation_id).first - return unless failure - - object = representation_class.from_json_hash(hash) - - add_identifiers_to_failure(failure, object.github_identifiers) - end - def increment_object_counter?(_object) true end @@ -118,16 +100,20 @@ module Gitlab extra.merge( project_id: project_id, importer: importer_class.name, - github_identifiers: github_identifiers + external_identifiers: github_identifiers ) end def track_exception(project, exception, fail_import: false) + external_identifiers = github_identifiers || {} + external_identifiers[:object_type] ||= object_type&.to_s + Gitlab::Import::ImportFailureService.track( project_id: project.id, error_source: importer_class.name, exception: exception, - fail_import: fail_import + fail_import: fail_import, + external_identifiers: external_identifiers ) end @@ -136,12 +122,6 @@ module Gitlab raise(exception) end - - def add_identifiers_to_failure(failure, external_identifiers) - external_identifiers[:object_type] = object_type - - failure.update_column(:external_identifiers, external_identifiers) - end end end end diff --git a/app/workers/concerns/gitlab/github_import/queue.rb b/app/workers/concerns/gitlab/github_import/queue.rb index e7156ac12f8..7cc23dd7c0b 100644 --- a/app/workers/concerns/gitlab/github_import/queue.rb +++ b/app/workers/concerns/gitlab/github_import/queue.rb @@ -15,14 +15,6 @@ module Gitlab # this is better than a project being stuck in the "import" state # forever. sidekiq_options dead: false, retry: 5 - - sidekiq_retries_exhausted do |msg, e| - Gitlab::Import::ImportFailureService.track( - project_id: msg['args'][0], - exception: e, - fail_import: true - ) - end end end end diff --git a/app/workers/concerns/gitlab/github_import/stage_methods.rb b/app/workers/concerns/gitlab/github_import/stage_methods.rb index 75db5589415..80013ff3cd9 100644 --- a/app/workers/concerns/gitlab/github_import/stage_methods.rb +++ b/app/workers/concerns/gitlab/github_import/stage_methods.rb @@ -3,6 +3,21 @@ module Gitlab module GithubImport module StageMethods + extend ActiveSupport::Concern + + included do + include ApplicationWorker + + sidekiq_retries_exhausted do |msg, e| + Gitlab::Import::ImportFailureService.track( + project_id: msg['args'][0], + exception: e, + error_source: self.class.name, + fail_import: true + ) + end + end + # project_id - The ID of the GitLab project to import the data into. def perform(project_id) info(project_id, message: 'starting stage') @@ -29,7 +44,8 @@ module Gitlab project_id: project_id, exception: e, error_source: self.class.name, - fail_import: abort_on_failure + fail_import: false, + metrics: true ) raise(e) @@ -51,10 +67,6 @@ module Gitlab # rubocop: enable CodeReuse/ActiveRecord end - def abort_on_failure - false - end - private def info(project_id, extra = {}) diff --git a/app/workers/concerns/limited_capacity/job_tracker.rb b/app/workers/concerns/limited_capacity/job_tracker.rb index 4b5ce8a01f6..b4d884f914d 100644 --- a/app/workers/concerns/limited_capacity/job_tracker.rb +++ b/app/workers/concerns/limited_capacity/job_tracker.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + module LimitedCapacity class JobTracker # rubocop:disable Scalability/IdempotentWorker include Gitlab::Utils::StrongMemoize diff --git a/app/workers/concerns/limited_capacity/worker.rb b/app/workers/concerns/limited_capacity/worker.rb index af66d80b3e9..0a79c5c46d5 100644 --- a/app/workers/concerns/limited_capacity/worker.rb +++ b/app/workers/concerns/limited_capacity/worker.rb @@ -1,41 +1,5 @@ # frozen_string_literal: true -# Usage: -# -# Worker that performs the tasks: -# -# class DummyWorker -# include ApplicationWorker -# include LimitedCapacity::Worker -# -# # For each job that raises any error, a worker instance will be disabled -# # until the next schedule-run. -# # If you wish to get around this, exceptions must by handled by the implementer. -# # -# def perform_work(*args) -# end -# -# def remaining_work_count(*args) -# 5 -# end -# -# def max_running_jobs -# 25 -# end -# end -# -# Cron worker to fill the pool of regular workers: -# -# class ScheduleDummyCronWorker -# include ApplicationWorker -# include CronjobQueue -# -# def perform(*args) -# DummyWorker.perform_with_capacity(*args) -# end -# end -# - module LimitedCapacity module Worker extend ActiveSupport::Concern diff --git a/app/workers/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb index 02eda924b71..cb09aaf1a6a 100644 --- a/app/workers/concerns/worker_attributes.rb +++ b/app/workers/concerns/worker_attributes.rb @@ -155,6 +155,10 @@ module WorkerAttributes ::Gitlab::SidekiqMiddleware::PauseControl::WorkersMap.set_strategy_for(strategy: value, worker: self) end + def get_pause_control + ::Gitlab::SidekiqMiddleware::PauseControl::WorkersMap.strategy_for(worker: self) + end + def get_weight get_class_attribute(:weight) || NAMESPACE_WEIGHTS[queue_namespace] || 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 58b0f5496f4..417af4c7172 100644 --- a/app/workers/database/batched_background_migration/ci_database_worker.rb +++ b/app/workers/database/batched_background_migration/ci_database_worker.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + module Database module BatchedBackgroundMigration class CiDatabaseWorker # rubocop:disable Scalability/IdempotentWorker diff --git a/app/workers/delete_container_repository_worker.rb b/app/workers/delete_container_repository_worker.rb deleted file mode 100644 index d0552dce9fc..00000000000 --- a/app/workers/delete_container_repository_worker.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -class DeleteContainerRepositoryWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker - include ExclusiveLeaseGuard - - data_consistency :always - - sidekiq_options retry: 3 - - queue_namespace :container_repository - feature_category :container_registry - - def perform(current_user_id, container_repository_id); end -end diff --git a/app/workers/environments/stop_job_failed_worker.rb b/app/workers/environments/stop_job_failed_worker.rb new file mode 100644 index 00000000000..c04601e0428 --- /dev/null +++ b/app/workers/environments/stop_job_failed_worker.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Environments + class StopJobFailedWorker + include ApplicationWorker + + data_consistency :delayed + idempotent! + feature_category :continuous_delivery + + def perform(job_id, _params = {}) + Ci::Processable.find_by_id(job_id).try do |job| + revert_environment(job) if job.stops_environment? && job.failed? + end + end + + private + + def revert_environment(job) + return if job.persisted_environment.nil? + + job.persisted_environment.fire_state_event(:recover_stuck_stopping) + end + end +end diff --git a/app/workers/gitlab/bitbucket_import/advance_stage_worker.rb b/app/workers/gitlab/bitbucket_import/advance_stage_worker.rb index 7f281352a1b..ed89f332652 100644 --- a/app/workers/gitlab/bitbucket_import/advance_stage_worker.rb +++ b/app/workers/gitlab/bitbucket_import/advance_stage_worker.rb @@ -20,13 +20,23 @@ module Gitlab # The known importer stages and their corresponding Sidekiq workers. STAGES = { + repository: Stage::ImportRepositoryWorker, + pull_requests: Stage::ImportPullRequestsWorker, + pull_requests_notes: Stage::ImportPullRequestsNotesWorker, + issues: Stage::ImportIssuesWorker, + issues_notes: Stage::ImportIssuesNotesWorker, + lfs_objects: Stage::ImportLfsObjectsWorker, finish: Stage::FinishImportWorker }.freeze - def find_import_state(project_id) + def find_import_state_jid(project_id) ProjectImportState.jid_by(project_id: project_id, status: :started) end + def find_import_state(id) + ProjectImportState.find(id) + end + private def next_stage_worker(next_stage) diff --git a/app/workers/gitlab/bitbucket_import/import_issue_notes_worker.rb b/app/workers/gitlab/bitbucket_import/import_issue_notes_worker.rb new file mode 100644 index 00000000000..de8239f30d9 --- /dev/null +++ b/app/workers/gitlab/bitbucket_import/import_issue_notes_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Gitlab + module BitbucketImport + class ImportIssueNotesWorker # rubocop:disable Scalability/IdempotentWorker + include ObjectImporter + + def importer_class + Importers::IssueNotesImporter + end + end + end +end diff --git a/app/workers/gitlab/bitbucket_import/import_issue_worker.rb b/app/workers/gitlab/bitbucket_import/import_issue_worker.rb new file mode 100644 index 00000000000..7df3f6d4a62 --- /dev/null +++ b/app/workers/gitlab/bitbucket_import/import_issue_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Gitlab + module BitbucketImport + class ImportIssueWorker # rubocop:disable Scalability/IdempotentWorker + include ObjectImporter + + def importer_class + Importers::IssueImporter + end + end + end +end diff --git a/app/workers/gitlab/bitbucket_import/import_lfs_object_worker.rb b/app/workers/gitlab/bitbucket_import/import_lfs_object_worker.rb new file mode 100644 index 00000000000..39b66684026 --- /dev/null +++ b/app/workers/gitlab/bitbucket_import/import_lfs_object_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Gitlab + module BitbucketImport + class ImportLfsObjectWorker # rubocop:disable Scalability/IdempotentWorker + include ObjectImporter + + def importer_class + Importers::LfsObjectImporter + end + end + end +end diff --git a/app/workers/gitlab/bitbucket_import/import_pull_request_notes_worker.rb b/app/workers/gitlab/bitbucket_import/import_pull_request_notes_worker.rb new file mode 100644 index 00000000000..8c9f84c97a5 --- /dev/null +++ b/app/workers/gitlab/bitbucket_import/import_pull_request_notes_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Gitlab + module BitbucketImport + class ImportPullRequestNotesWorker # rubocop:disable Scalability/IdempotentWorker + include ObjectImporter + + def importer_class + Importers::PullRequestNotesImporter + end + end + end +end diff --git a/app/workers/gitlab/bitbucket_import/stage/import_issues_notes_worker.rb b/app/workers/gitlab/bitbucket_import/stage/import_issues_notes_worker.rb new file mode 100644 index 00000000000..cbd67099086 --- /dev/null +++ b/app/workers/gitlab/bitbucket_import/stage/import_issues_notes_worker.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Gitlab + module BitbucketImport + module Stage + class ImportIssuesNotesWorker # rubocop:disable Scalability/IdempotentWorker + include StageMethods + + private + + # project - An instance of Project. + def import(project) + waiter = importer_class.new(project).execute + + project.import_state.refresh_jid_expiration + + AdvanceStageWorker.perform_async( + project.id, + { waiter.key => waiter.jobs_remaining }, + :lfs_objects + ) + end + + def importer_class + Importers::IssuesNotesImporter + end + end + end + end +end diff --git a/app/workers/gitlab/bitbucket_import/stage/import_issues_worker.rb b/app/workers/gitlab/bitbucket_import/stage/import_issues_worker.rb new file mode 100644 index 00000000000..31a11d802c7 --- /dev/null +++ b/app/workers/gitlab/bitbucket_import/stage/import_issues_worker.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Gitlab + module BitbucketImport + module Stage + class ImportIssuesWorker # rubocop:disable Scalability/IdempotentWorker + include StageMethods + + private + + # project - An instance of Project. + def import(project) + waiter = importer_class.new(project).execute + + project.import_state.refresh_jid_expiration + + AdvanceStageWorker.perform_async( + project.id, + { waiter.key => waiter.jobs_remaining }, + :issues_notes + ) + end + + def importer_class + Importers::IssuesImporter + end + end + end + end +end diff --git a/app/workers/gitlab/bitbucket_import/stage/import_lfs_objects_worker.rb b/app/workers/gitlab/bitbucket_import/stage/import_lfs_objects_worker.rb new file mode 100644 index 00000000000..c88a1be3446 --- /dev/null +++ b/app/workers/gitlab/bitbucket_import/stage/import_lfs_objects_worker.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Gitlab + module BitbucketImport + module Stage + class ImportLfsObjectsWorker # rubocop:disable Scalability/IdempotentWorker + include StageMethods + + private + + # project - An instance of Project. + def import(project) + waiter = importer_class.new(project).execute + + project.import_state.refresh_jid_expiration + + AdvanceStageWorker.perform_async( + project.id, + { waiter.key => waiter.jobs_remaining }, + :finish + ) + end + + def importer_class + Importers::LfsObjectsImporter + end + end + end + end +end diff --git a/app/workers/gitlab/bitbucket_import/stage/import_pull_requests_notes_worker.rb b/app/workers/gitlab/bitbucket_import/stage/import_pull_requests_notes_worker.rb new file mode 100644 index 00000000000..36d60c7246c --- /dev/null +++ b/app/workers/gitlab/bitbucket_import/stage/import_pull_requests_notes_worker.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Gitlab + module BitbucketImport + module Stage + class ImportPullRequestsNotesWorker # rubocop:disable Scalability/IdempotentWorker + include StageMethods + + private + + # project - An instance of Project. + def import(project) + waiter = importer_class.new(project).execute + + project.import_state.refresh_jid_expiration + + AdvanceStageWorker.perform_async( + project.id, + { waiter.key => waiter.jobs_remaining }, + :issues + ) + end + + def importer_class + Importers::PullRequestsNotesImporter + end + end + end + end +end diff --git a/app/workers/gitlab/bitbucket_import/stage/import_pull_requests_worker.rb b/app/workers/gitlab/bitbucket_import/stage/import_pull_requests_worker.rb index e1f3b5ab79a..3f85c832d50 100644 --- a/app/workers/gitlab/bitbucket_import/stage/import_pull_requests_worker.rb +++ b/app/workers/gitlab/bitbucket_import/stage/import_pull_requests_worker.rb @@ -17,7 +17,7 @@ module Gitlab AdvanceStageWorker.perform_async( project.id, { waiter.key => waiter.jobs_remaining }, - :finish + :pull_requests_notes ) end diff --git a/app/workers/gitlab/bitbucket_server_import/advance_stage_worker.rb b/app/workers/gitlab/bitbucket_server_import/advance_stage_worker.rb index 2c8db639725..1fc35725c9f 100644 --- a/app/workers/gitlab/bitbucket_server_import/advance_stage_worker.rb +++ b/app/workers/gitlab/bitbucket_server_import/advance_stage_worker.rb @@ -25,10 +25,14 @@ module Gitlab finish: Stage::FinishImportWorker }.freeze - def find_import_state(project_id) + def find_import_state_jid(project_id) ProjectImportState.jid_by(project_id: project_id, status: :started) end + def find_import_state(id) + ProjectImportState.find(id) + end + private def next_stage_worker(next_stage) diff --git a/app/workers/gitlab/github_gists_import/import_gist_worker.rb b/app/workers/gitlab/github_gists_import/import_gist_worker.rb index 60e4c8fdad6..151788150dd 100644 --- a/app/workers/gitlab/github_gists_import/import_gist_worker.rb +++ b/app/workers/gitlab/github_gists_import/import_gist_worker.rb @@ -106,9 +106,9 @@ module Gitlab def error(user_id, error_message, github_identifiers) attributes = { user_id: user_id, - github_identifiers: github_identifiers, + external_identifiers: github_identifiers, message: 'importer failed', - 'error.message': error_message + 'exception.message': error_message } Gitlab::GithubImport::Logger.error(structured_payload(attributes)) @@ -120,7 +120,7 @@ module Gitlab attributes = { user_id: user_id, message: message, - github_identifiers: gist_id + external_identifiers: gist_id } Gitlab::GithubImport::Logger.info(structured_payload(attributes)) diff --git a/app/workers/gitlab/github_gists_import/start_import_worker.rb b/app/workers/gitlab/github_gists_import/start_import_worker.rb index 33c91611719..f7d3eb1d759 100644 --- a/app/workers/gitlab/github_gists_import/start_import_worker.rb +++ b/app/workers/gitlab/github_gists_import/start_import_worker.rb @@ -51,7 +51,7 @@ module Gitlab end def log_error_and_raise!(user_id, error) - logger.error(structured_payload(user_id: user_id, message: 'import failed', 'error.message': error.message)) + logger.error(structured_payload(user_id: user_id, message: 'import failed', 'exception.message': error.message)) raise(error) end diff --git a/app/workers/gitlab/github_import/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb index 45f4bf486d7..a012241e90c 100644 --- a/app/workers/gitlab/github_import/advance_stage_worker.rb +++ b/app/workers/gitlab/github_import/advance_stage_worker.rb @@ -33,10 +33,14 @@ module Gitlab finish: Stage::FinishImportWorker }.freeze - def find_import_state(project_id) + def find_import_state_jid(project_id) ProjectImportState.jid_by(project_id: project_id, status: :started) end + def find_import_state(id) + ProjectImportState.find(id) + end + private def next_stage_worker(next_stage) diff --git a/app/workers/gitlab/github_import/refresh_import_jid_worker.rb b/app/workers/gitlab/github_import/refresh_import_jid_worker.rb index 2b9fb26d53a..3de4bef053f 100644 --- a/app/workers/gitlab/github_import/refresh_import_jid_worker.rb +++ b/app/workers/gitlab/github_import/refresh_import_jid_worker.rb @@ -7,7 +7,6 @@ module Gitlab data_consistency :always - sidekiq_options retry: 3 include GithubImport::Queue # The interval to schedule new instances of this job at. diff --git a/app/workers/gitlab/github_import/stage/finish_import_worker.rb b/app/workers/gitlab/github_import/stage/finish_import_worker.rb index e716eda5c99..90445a6d46c 100644 --- a/app/workers/gitlab/github_import/stage/finish_import_worker.rb +++ b/app/workers/gitlab/github_import/stage/finish_import_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - sidekiq_options retry: 3 include GithubImport::Queue include StageMethods diff --git a/app/workers/gitlab/github_import/stage/import_attachments_worker.rb b/app/workers/gitlab/github_import/stage/import_attachments_worker.rb index 4045852e3f0..f9952f04e99 100644 --- a/app/workers/gitlab/github_import/stage/import_attachments_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_attachments_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - sidekiq_options retry: 5 include GithubImport::Queue include StageMethods diff --git a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb index cc6a2255160..94cb3cb6c71 100644 --- a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - sidekiq_options retry: 3 include GithubImport::Queue include StageMethods @@ -31,22 +30,6 @@ module Gitlab project.import_state.refresh_jid_expiration ImportPullRequestsWorker.perform_async(project.id) - rescue StandardError => e - Gitlab::Import::ImportFailureService.track( - project_id: project.id, - error_source: self.class.name, - exception: e, - fail_import: abort_on_failure, - metrics: true - ) - - raise(e) - end - - private - - def abort_on_failure - true end end end diff --git a/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb b/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb index 8f72cc051b3..751ca92388a 100644 --- a/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - sidekiq_options retry: 3 include GithubImport::Queue include StageMethods @@ -24,16 +23,6 @@ module Gitlab project.import_state.refresh_jid_expiration move_to_next_stage(project, { waiter.key => waiter.jobs_remaining }) - rescue StandardError => e - Gitlab::Import::ImportFailureService.track( - project_id: project.id, - error_source: self.class.name, - exception: e, - fail_import: abort_on_failure, - metrics: true - ) - - raise(e) end private @@ -58,10 +47,6 @@ module Gitlab project.id, waiters, :pull_requests_merged_by ) end - - def abort_on_failure - true - end end end end diff --git a/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb b/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb index 54ed4c47e78..c80412d941b 100644 --- a/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - sidekiq_options retry: 3 include GithubImport::Queue include StageMethods diff --git a/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb b/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb index 3d1a8437da2..592b789cc94 100644 --- a/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - sidekiq_options retry: 3 include GithubImport::Queue include StageMethods diff --git a/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb b/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb index f6f5687130f..e89a850c991 100644 --- a/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - sidekiq_options retry: 3 include GithubImport::Queue include StageMethods diff --git a/app/workers/gitlab/github_import/stage/import_notes_worker.rb b/app/workers/gitlab/github_import/stage/import_notes_worker.rb index 40ca12b130f..c1fdb76d03e 100644 --- a/app/workers/gitlab/github_import/stage/import_notes_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_notes_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - sidekiq_options retry: 3 include GithubImport::Queue include StageMethods diff --git a/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb b/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb index 73f4ea580c4..f8448094c28 100644 --- a/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - sidekiq_options retry: 3 include GithubImport::Queue include StageMethods @@ -27,15 +26,6 @@ module Gitlab { waiter.key => waiter.jobs_remaining }, :lfs_objects ) - rescue StandardError => e - Gitlab::Import::ImportFailureService.track( - project_id: project.id, - error_source: self.class.name, - exception: e, - metrics: true - ) - - raise(e) end end end diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb index 329bf8f84b1..2e7cd28578f 100644 --- a/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - sidekiq_options retry: 3 include GithubImport::Queue include StageMethods diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_review_requests_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_review_requests_worker.rb index bcbf5dd471a..2f860349e25 100644 --- a/app/workers/gitlab/github_import/stage/import_pull_requests_review_requests_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_review_requests_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - sidekiq_options retry: 3 include GithubImport::Queue include StageMethods diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb index 33dee47bd03..51730033133 100644 --- a/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - sidekiq_options retry: 3 include GithubImport::Queue include StageMethods diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb index b2dfded0280..029d38d8b93 100644 --- a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - sidekiq_options retry: 3 include GithubImport::Queue include StageMethods @@ -33,16 +32,6 @@ module Gitlab { waiter.key => waiter.jobs_remaining }, :collaborators ) - rescue StandardError => e - Gitlab::Import::ImportFailureService.track( - project_id: project.id, - error_source: self.class.name, - exception: e, - fail_import: abort_on_failure, - metrics: true - ) - - raise(e) end private @@ -57,10 +46,6 @@ module Gitlab MergeRequest.track_target_project_iid!(project, last_github_pull_request[:number]) end - - def abort_on_failure - true - end end end end diff --git a/app/workers/gitlab/github_import/stage/import_repository_worker.rb b/app/workers/gitlab/github_import/stage/import_repository_worker.rb index d998771b328..2a62930b5ea 100644 --- a/app/workers/gitlab/github_import/stage/import_repository_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_repository_worker.rb @@ -8,7 +8,6 @@ module Gitlab data_consistency :always - sidekiq_options retry: 3 include GithubImport::Queue include StageMethods @@ -34,17 +33,6 @@ module Gitlab counter.increment ImportBaseDataWorker.perform_async(project.id) - - rescue StandardError => e - Gitlab::Import::ImportFailureService.track( - project_id: project.id, - error_source: self.class.name, - exception: e, - fail_import: abort_on_failure, - metrics: true - ) - - raise(e) end def counter @@ -54,10 +42,6 @@ module Gitlab ) end - def abort_on_failure - true - end - private def allocate_issues_internal_id!(project, client) diff --git a/app/workers/gitlab/import/advance_stage.rb b/app/workers/gitlab/import/advance_stage.rb index 5d5abc88388..180c08905ff 100644 --- a/app/workers/gitlab/import/advance_stage.rb +++ b/app/workers/gitlab/import/advance_stage.rb @@ -4,6 +4,9 @@ module Gitlab module Import module AdvanceStage INTERVAL = 30.seconds.to_i + TIMEOUT_DURATION = 2.hours + + AdvanceStageTimeoutError = Class.new(StandardError) # The number of seconds to wait (while blocking the thread) before # continuing to the next waiter. @@ -14,30 +17,35 @@ module Gitlab # remaining jobs. # next_stage - The name of the next stage to start when all jobs have been # completed. - def perform(project_id, waiters, next_stage) - import_state = find_import_state(project_id) + # timeout_timer - Time the sidekiq worker was first initiated with the current job_count + # previous_job_count - Number of jobs remaining on last invocation of this worker + def perform(project_id, waiters, next_stage, timeout_timer = Time.zone.now, previous_job_count = nil) + import_state_jid = find_import_state_jid(project_id) # If the import state is nil the project may have been deleted or the import # may have failed or been canceled. In this case we tidy up the cache data and no # longer attempt to advance to the next stage. - if import_state.nil? + if import_state_jid.nil? clear_waiter_caches(waiters) return end new_waiters = wait_for_jobs(waiters) + new_job_count = new_waiters.values.sum + + # Reset the timeout timer as some jobs finished processing + if new_job_count != previous_job_count + timeout_timer = Time.zone.now + previous_job_count = new_job_count + end if new_waiters.empty? - # We refresh the import JID here so workers importing individual - # resources (e.g. notes) don't have to do this all the time, reducing - # the pressure on Redis. We _only_ do this once all jobs are done so - # we don't get stuck forever if one or more jobs failed to notify the - # JobWaiter. - import_state.refresh_jid_expiration - - next_stage_worker(next_stage).perform_async(project_id) + proceed_to_next_stage(import_state_jid, next_stage, project_id) + elsif timeout_reached?(timeout_timer) && new_job_count == previous_job_count + + handle_timeout(import_state_jid, next_stage, project_id, new_waiters, new_job_count) else - self.class.perform_in(INTERVAL, project_id, new_waiters, next_stage) + self.class.perform_in(INTERVAL, project_id, new_waiters, next_stage, timeout_timer, previous_job_count) end end @@ -55,12 +63,66 @@ module Gitlab end end - def find_import_state(project_id) + def find_import_state_jid(project_id) + raise NotImplementedError + end + + def find_import_state(id) raise NotImplementedError end private + def proceed_to_next_stage(import_state_jid, next_stage, project_id) + # We refresh the import JID here so workers importing individual + # resources (e.g. notes) don't have to do this all the time, reducing + # the pressure on Redis. We _only_ do this once all jobs are done so + # we don't get stuck forever if one or more jobs failed to notify the + # JobWaiter. + import_state_jid.refresh_jid_expiration + + next_stage_worker(next_stage).perform_async(project_id) + end + + def handle_timeout(import_state_jid, next_stage, project_id, new_waiters, new_job_count) + project = Project.find_by_id(project_id) + strategy = project.import_data&.data&.dig("timeout_strategy") || ProjectImportData::PESSIMISTIC_TIMEOUT + + Gitlab::Import::Logger.info( + message: 'Timeout reached, no longer retrying', + project_id: project_id, + jobs_remaining: new_job_count, + waiters: new_waiters, + timeout_strategy: strategy + ) + + clear_waiter_caches(new_waiters) + + case strategy + when ProjectImportData::OPTIMISTIC_TIMEOUT + proceed_to_next_stage(import_state_jid, next_stage, project_id) + when ProjectImportData::PESSIMISTIC_TIMEOUT + import_state = find_import_state(import_state_jid.id) + fail_import_and_log_status(import_state) + end + end + + def fail_import_and_log_status(import_state) + raise AdvanceStageTimeoutError, "Failing advance stage, timeout reached with pessimistic strategy" + rescue AdvanceStageTimeoutError => e + Gitlab::Import::ImportFailureService.track( + import_state: import_state, + exception: e, + error_source: self.class.name, + fail_import: true + ) + end + + def timeout_reached?(timeout_timer) + timeout_timer = Time.zone.parse(timeout_timer) if timeout_timer.is_a?(String) + Time.zone.now > timeout_timer + TIMEOUT_DURATION + end + def next_stage_worker(next_stage) raise NotImplementedError end diff --git a/app/workers/gitlab/import/stuck_project_import_jobs_worker.rb b/app/workers/gitlab/import/stuck_project_import_jobs_worker.rb index 01979b2029f..93d670e1b8b 100644 --- a/app/workers/gitlab/import/stuck_project_import_jobs_worker.rb +++ b/app/workers/gitlab/import/stuck_project_import_jobs_worker.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + module Gitlab module Import class StuckProjectImportJobsWorker # rubocop:disable Scalability/IdempotentWorker diff --git a/app/workers/gitlab/jira_import/advance_stage_worker.rb b/app/workers/gitlab/jira_import/advance_stage_worker.rb index 5fae7caf791..9641b55a584 100644 --- a/app/workers/gitlab/jira_import/advance_stage_worker.rb +++ b/app/workers/gitlab/jira_import/advance_stage_worker.rb @@ -20,10 +20,14 @@ module Gitlab finish: Gitlab::JiraImport::Stage::FinishImportWorker }.freeze - def find_import_state(project_id) + def find_import_state_jid(project_id) JiraImportState.jid_by(project_id: project_id, status: :started) end + def find_import_state(id) + JiraImportState.find(id) + end + private def next_stage_worker(next_stage) diff --git a/app/workers/gitlab_shell_worker.rb b/app/workers/gitlab_shell_worker.rb deleted file mode 100644 index b3c0fa79658..00000000000 --- a/app/workers/gitlab_shell_worker.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -class GitlabShellWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker - - data_consistency :always - - sidekiq_options retry: 3 - include Gitlab::ShellAdapter - - feature_category :source_code_management - urgency :high - weight 2 - loggable_arguments 0 - - def perform(action, *arg) - if Gitlab::Shell::PERMITTED_ACTIONS.exclude?(action) - raise(ArgumentError, "#{action} not allowed for #{self.class.name}") - end - - Gitlab::GitalyClient::NamespaceService.allow do - gitlab_shell.public_send(action, *arg) # rubocop:disable GitlabSecurity/PublicSend - end - end -end diff --git a/app/workers/hashed_storage/migrator_worker.rb b/app/workers/hashed_storage/migrator_worker.rb index 5f90b8f1009..a7e7a505681 100644 --- a/app/workers/hashed_storage/migrator_worker.rb +++ b/app/workers/hashed_storage/migrator_worker.rb @@ -13,9 +13,6 @@ module HashedStorage # @param [Integer] start initial ID of the batch # @param [Integer] finish last ID of the batch - def perform(start, finish) - migrator = Gitlab::HashedStorage::Migrator.new - migrator.bulk_migrate(start: start, finish: finish) - end + def perform(start, finish); end end end diff --git a/app/workers/hashed_storage/project_migrate_worker.rb b/app/workers/hashed_storage/project_migrate_worker.rb index 01e2d6307de..e1bf71de179 100644 --- a/app/workers/hashed_storage/project_migrate_worker.rb +++ b/app/workers/hashed_storage/project_migrate_worker.rb @@ -13,17 +13,6 @@ module HashedStorage attr_reader :project_id - def perform(project_id, old_disk_path = nil) - @project_id = project_id # we need to set this in order to create the lease_key - - try_obtain_lease do - project = Project.without_deleted.find_by_id(project_id) - break unless project && project.storage_upgradable? - - old_disk_path ||= Storage::LegacyProject.new(project).disk_path - - ::Projects::HashedStorage::MigrationService.new(project, old_disk_path, logger: logger).execute - end - end + def perform(project_id, old_disk_path = nil); end end end diff --git a/app/workers/hashed_storage/project_rollback_worker.rb b/app/workers/hashed_storage/project_rollback_worker.rb index 2ec323248ab..af4223ff354 100644 --- a/app/workers/hashed_storage/project_rollback_worker.rb +++ b/app/workers/hashed_storage/project_rollback_worker.rb @@ -13,17 +13,6 @@ module HashedStorage attr_reader :project_id - def perform(project_id, old_disk_path = nil) - @project_id = project_id # we need to set this in order to create the lease_key - - try_obtain_lease do - project = Project.without_deleted.find_by_id(project_id) - break unless project - - old_disk_path ||= project.disk_path - - ::Projects::HashedStorage::RollbackService.new(project, old_disk_path, logger: logger).execute - end - end + def perform(project_id, old_disk_path = nil); end end end diff --git a/app/workers/hashed_storage/rollbacker_worker.rb b/app/workers/hashed_storage/rollbacker_worker.rb index c6c4990d799..e659e65a370 100644 --- a/app/workers/hashed_storage/rollbacker_worker.rb +++ b/app/workers/hashed_storage/rollbacker_worker.rb @@ -13,9 +13,6 @@ module HashedStorage # @param [Integer] start initial ID of the batch # @param [Integer] finish last ID of the batch - def perform(start, finish) - migrator = Gitlab::HashedStorage::Migrator.new - migrator.bulk_rollback(start: start, finish: finish) - end + def perform(start, finish); end end end diff --git a/app/workers/integrations/irker_worker.rb b/app/workers/integrations/irker_worker.rb index 3152d68b372..4c1f0df0fc7 100644 --- a/app/workers/integrations/irker_worker.rb +++ b/app/workers/integrations/irker_worker.rb @@ -58,7 +58,7 @@ module Integrations allow_local_network: allow_local_requests?, schemes: ['irc']) @socket = TCPSocket.new ip_address, irker_port - rescue Errno::ECONNREFUSED, Gitlab::UrlBlocker::BlockedUrlError => e + rescue Errno::ECONNREFUSED, Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError => e logger.fatal "Can't connect to Irker daemon: #{e}" return false end diff --git a/app/workers/issuable/related_links_create_worker.rb b/app/workers/issuable/related_links_create_worker.rb new file mode 100644 index 00000000000..7cbf70fd5ab --- /dev/null +++ b/app/workers/issuable/related_links_create_worker.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Issuable + class RelatedLinksCreateWorker + include ApplicationWorker + + data_consistency :delayed + + sidekiq_options retry: 3 + + feature_category :portfolio_management + worker_resource_boundary :unknown + urgency :high + idempotent! + + def perform(args) + @params = args.with_indifferent_access + @user = User.find_by_id(params[:user_id]) + @issuable = issuable_class.find_by_id(params[:issuable_id]) + @links = issuable_class.related_link_class&.where(id: params[:link_ids]) + return unless user && issuable && links.present? + + create_issuable_notes! + rescue ArgumentError => error + logger.error( + worker: self.class.name, + message: "Failed to complete job (user_id:#{params[:user_id]}, issuable_id:#{params[:issuable_id]}, " \ + "issuable_class:#{params[:issuable_class]}): #{error.message}" + ) + end + + private + + attr_reader :params, :user, :issuable, :links + + def issuable_class + params[:issuable_class].constantize + rescue NameError + raise ArgumentError, "Unknown class '#{params[:issuable_class]}'" + end + + def create_issuable_notes! + errors = create_notes.compact + return unless errors.any? + + raise ArgumentError, "Could not create notes: #{errors.join(', ')}" + end + + def create_notes + linked_item_notes_errors = links.filter_map { |link| create_system_note(link.target, issuable) } + issuable_note_error = create_system_note(issuable, links.collect(&:target)) + + linked_item_notes_errors << issuable_note_error + end + + def create_system_note(noteable, references, method_name = :relate_issuable) + note = ::SystemNoteService.try(method_name, noteable, references, user) + return if note.present? + + "{noteable_id: #{noteable.id}, reference_ids: #{[references].flatten.collect(&:id)}}" + end + end +end + +Issuable::RelatedLinksCreateWorker.prepend_mod_with('Issuable::RelatedLinksCreateWorker') diff --git a/app/workers/jira_connect/sync_project_worker.rb b/app/workers/jira_connect/sync_project_worker.rb index 40f225ab756..09aa5edc73b 100644 --- a/app/workers/jira_connect/sync_project_worker.rb +++ b/app/workers/jira_connect/sync_project_worker.rb @@ -33,7 +33,10 @@ module JiraConnect # rubocop: disable CodeReuse/ActiveRecord def merge_requests_to_sync(project) - project.merge_requests.with_jira_issue_keys.preload(:author).limit(MAX_RECORDS_LIMIT).order(id: :desc) + project.merge_requests.with_jira_issue_keys + .preload(:author, :approvals, merge_request_reviewers: :reviewer) + .limit(MAX_RECORDS_LIMIT) + .order(id: :desc) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb index a0594b15e31..29f0c0bbbf4 100644 --- a/app/workers/merge_worker.rb +++ b/app/workers/merge_worker.rb @@ -16,8 +16,6 @@ class MergeWorker # rubocop:disable Scalability/IdempotentWorker deduplicate :until_executed, including_scheduled: true def perform(merge_request_id, current_user_id, params) - params = params.with_indifferent_access - begin current_user = User.find(current_user_id) merge_request = MergeRequest.find(merge_request_id) @@ -25,6 +23,9 @@ class MergeWorker # rubocop:disable Scalability/IdempotentWorker return end + params = params.with_indifferent_access + params[:check_mergeability_retry_lease] = true unless params.has_key?(:check_mergeability_retry_lease) + MergeRequests::MergeService.new(project: merge_request.target_project, current_user: current_user, params: params) .execute(merge_request) end diff --git a/app/workers/pages/deactivated_deployments_delete_cron_worker.rb b/app/workers/pages/deactivated_deployments_delete_cron_worker.rb new file mode 100644 index 00000000000..7ee6327cea7 --- /dev/null +++ b/app/workers/pages/deactivated_deployments_delete_cron_worker.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Pages + class DeactivatedDeploymentsDeleteCronWorker + include ApplicationWorker + include CronjobQueue # rubocop: disable Scalability/CronWorkerContext + + idempotent! + data_consistency :always # rubocop: disable SidekiqLoadBalancing/WorkerDataConsistency + + feature_category :pages + + def perform + PagesDeployment.deactivated.each_batch do |deployments| # rubocop: disable Style/SymbolProc + deployments.delete_all + end + end + end +end diff --git a/app/workers/projects/after_import_worker.rb b/app/workers/projects/after_import_worker.rb index 06211b2d991..47bd07d0850 100644 --- a/app/workers/projects/after_import_worker.rb +++ b/app/workers/projects/after_import_worker.rb @@ -31,7 +31,7 @@ module Projects message: 'Project housekeeping failed', project_full_path: @project.full_path, project_id: @project.id, - 'error.message' => e.message + 'exception.message' => e.message ) end diff --git a/app/workers/projects/record_target_platforms_worker.rb b/app/workers/projects/record_target_platforms_worker.rb index bbe0c63cfd1..d458c9563d0 100644 --- a/app/workers/projects/record_target_platforms_worker.rb +++ b/app/workers/projects/record_target_platforms_worker.rb @@ -8,7 +8,7 @@ module Projects LEASE_TIMEOUT = 1.hour.to_i APPLE_PLATFORM_LANGUAGES = %w[swift objective-c].freeze - feature_category :experimentation_activation + feature_category :activation data_consistency :always deduplicate :until_executed urgency :low diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb index dab92e16ee3..61ef7494d38 100644 --- a/app/workers/run_pipeline_schedule_worker.rb +++ b/app/workers/run_pipeline_schedule_worker.rb @@ -22,7 +22,7 @@ class RunPipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker options.symbolize_keys! if options[:scheduling] - return if schedule.next_run_at > Time.current + return if schedule.next_run_at.future? update_next_run_at_for(schedule) end diff --git a/app/workers/tasks_to_be_done/create_worker.rb b/app/workers/tasks_to_be_done/create_worker.rb index d3824ceb4ae..91046e3cfed 100644 --- a/app/workers/tasks_to_be_done/create_worker.rb +++ b/app/workers/tasks_to_be_done/create_worker.rb @@ -11,21 +11,8 @@ module TasksToBeDone worker_resource_boundary :cpu def perform(member_task_id, current_user_id, assignee_ids = []) - member_task = MemberTask.find(member_task_id) - current_user = User.find(current_user_id) - project = member_task.project - - member_task.tasks_to_be_done.each do |task| - service_class(task) - .new(container: project, current_user: current_user, assignee_ids: assignee_ids) - .execute - end - end - - private - - def service_class(task) - "TasksToBeDone::Create#{task.to_s.camelize}TaskService".constantize + # no-op removing + # https://docs.gitlab.com/ee/development/sidekiq/compatibility_across_updates.html#removing-worker-classes end end end |